fix: QA sweep - tool routes, security, i18n, a11y, + AI bundle install hardening (#393)

* fix(api): correct format/filename/container handling across tool routes

Found during a comprehensive QA sweep exercising every tool against its
full accepted-format matrix:

- watermark-image, compose: preserve the requested output format and a
  matching download filename/extension instead of always emitting the
  source format
- compose: crop oversized overlays to the visible base area instead of
  crashing Sharp's composite, and reject only overlays fully outside the
  base image instead of any oversized one
- compare, vectorize: switch to the shared image input handler so
  filenames and formats like .svgz/.tga/RAW survive validation instead
  of being rejected pre-processing
- tool-factory, images-to-video: normalize frames through Sharp before
  handing them to FFmpeg, fixing GIF/AVIF/RAW image-to-video jobs that
  previously failed or hung
- media-tool, replace-audio, embed-subtitles: fix legacy container
  MIME/codec handling for MPEG sources and subtitle remux cases
- files: expand download MIME mapping for text/data/document/video/audio
  outputs that were falling back to a generic content type
- convert-document/presentation/spreadsheet: same-format conversions now
  return the original validated file instead of erroring or producing
  corrupt tiny output

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(web): dropzone a11y, stale localStorage getter, dead code

- dropzone: stop making the whole drop-zone section clickable/focusable.
  A section acting as an interactive element around a real upload button
  is a nested-interactive-element anti-pattern that confuses screen
  readers; drag-and-drop doesn't need focus semantics, only the button
  fallback does. Keeps that button semantic and keyboard-reachable.
  Updates the two e2e call sites that clicked the section directly.
- api, use-auth: read through window.localStorage via the existing API
  storage helper instead of the bare global, which resolves to Node's
  experimental localStorage getter under Vitest and threw
- find-duplicates-settings, info-settings, login-page: remove dead code
  (unused zip-download handler, a stale mount-only effect dependency
  that left cached info stuck at reused indices, an unused response
  variable)

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(i18n): pt-BR, zh-CN, zh-TW were silently falling back to English

The locale loader looked up dynamic-import exports by the raw locale
code (mod["pt-BR"], mod["zh-CN"], mod["zh-TW"]), but those three modules
export camelCased bindings (ptBR, zhCN, zhTW) since identifiers can't
contain hyphens. The lookup returned undefined and every consumer
silently fell back to English for these three locales. Replaces the
generic lookup with explicit per-locale loaders so the mapping can't
drift out of sync again.

Also updates the dropzone helper copy across all 21 locales to match
the drag-only dropzone wording from the previous commit.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(docs): clear build warnings in the VitePress site

- config.mts: add an onwarn handler for the @vueuse INVALID_ANNOTATION
  warnings emitted during the docs build
- deployment.md: the caddyfile code fence language isn't a shiki grammar
  VitePress ships with, so it warned on every build; use txt instead

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* test(qa): update QA harness for the drag-only dropzone and regen metadata

- api-sweep, qa-helpers, verify-ai: add JSON-body tools, multi-input
  secondary fixtures, async polling for slow valid jobs, 501
  FEATURE_NOT_INSTALLED skip handling, and safer per-tool settings
- input-preview, pipeline-ui specs: update upload flow for the
  drag-only dropzone surface
- add tests/fixtures/data/valid/chart.json, a valid chart fixture the
  updated helpers route to
- regenerate tools-meta.json against current TOOLS[]

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(security): close a login timing side-channel, harden zip-slip tests

Found during a black-box security sweep of the real auth-enabled
production container: a nonexistent username returned 401 in ~3-10ms,
while a wrong password for a real user took ~35-42ms, because scrypt
verification only ran when a user row existed. That timing gap lets an
attacker enumerate valid usernames without ever guessing a password.
Now runs verification against a cached dummy hash on the unknown-user
path too, so both cases cost the same regardless of outcome.

extract-zip already had a relative-traversal regression test
(../evil.txt), but its absolute-path rejection branches
(name.startsWith("/") / startsWith("\\")) had none. Added the three
missing cases: deep relative traversal, absolute Unix path, and
Windows-style absolute path.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* test(qa): add UI-driven AI bundle install scripts

QA_PROMPT.md's Phase 2 requires installing AI models the way a user
does -- through the UI, on demand from HuggingFace -- and treats the
curl-based admin install endpoint as fallback-only. Nothing in the
harness actually drove that flow; tests/qa/seed-ai-models.sh installs
via docker exec + pip, which is further from a real user than even the
API fallback.

install-ai-bundles-ui.mts logs in, opens Settings > AI Features,
screenshots the pre-install state, clicks Install All, and screenshots
progress -- then exits, since installs continue server-side once
triggered. verify-ai-install-complete.mts polls bundle status,
screenshots the completed state, and runs one real tool per installed
bundle to prove the freshly-downloaded model actually executes.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(qa): correct the apiToolPath import in the AI verify script

Dynamic import of the package name failed under tsx's module resolution
from apps/api's node_modules context; use the same relative-path import
api-sweep.mts already uses successfully.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(web): correct AI bundle size estimates shown before install

Measured real downloads during GPU-node QA verification: photo-restoration
pulls ~4.4GB (was advertised as 800MB-1GB, off by 4-5x) and ocr pulls
~5.5GB (was advertised as 3-4GB). Both estimates only accounted for model
weights, not the pip dependencies (torch/paddle) that come down with them.
Updated to reflect actual total download size, since that's what a user
deciding whether they have the disk/bandwidth actually needs to know.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(web): make desktop Settings reachable when auth is disabled

AvatarDropdown (the only desktop entry point to Settings) was gated
behind `!isMobile && authEnabled`. With AUTH_ENABLED=false the synthetic
anonymous admin user should have full Settings access per how auth.ts
documents this mode -- and the mobile bottom nav already worked this way,
showing Settings unconditionally. Desktop just had a stray extra gate the
component doesn't need: AvatarDropdown already resolves its own username
internally (falling back to "admin") and reads authEnabled itself where
it actually matters (hiding the Logout button). Removed the outer gate;
verified end-to-end against a fresh AUTH_ENABLED=false instance -- avatar
now renders, Settings opens, shows the anonymous/Admin identity correctly.

Also documents (not changes) a related finding in install_feature.py:
detect_arch() always resolves amd64 hosts to the GPU-bundled archive
variant regardless of actual GPU presence, since no CPU-only amd64
archive is published to the bundle repo yet. Left as a code comment
rather than a behavior change, since requesting an unpublished archive
key would hard-fail installs entirely -- worse than the current
oversized-but-working download. Full detail in the QA report.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(ai): stop logging expected dispatcher reloads as crashes

After each AI bundle install the Python dispatcher reloads because the
venv changed, and after every app shutdown it's SIGTERMed. Both took the
close handler's `code !== 0` branch (SIGTERM makes the exit code null),
so they were counted as crashes -- producing an alarming "crash" line in
the logs and a pointless ~1s recovery backoff after each of 7 installs.
A `stopping` flag set in shutdown() lets the close handler tell an
intentional stop apart from a real crash. The request-timeout kill path
deliberately does not set it, so a genuinely hung script still records a
crash and the 5-in-60s permanent-disable threshold is untouched.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(api): return a clean message when content-aware resize times out

Carving a very high-resolution image down to a tiny target could exceed
the caire subprocess timeout, and the raw error forwarded to the user was
caire's terminal output -- ANSI color codes and progress-spinner control
characters -- instead of anything actionable. Now: the timeout path
throws a clear "timed out; try a smaller image or larger target" message
(keeping the raw stderr as `cause` for server logs); friendlyError()
strips ANSI/control chars centrally so any subprocess dump surfaced
through the shared sanitizer is plain text; and the content-aware-resize
route (a custom route that bypassed the sanitizer) now routes its error
paths through friendlyError like every other tool.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(ai): stop bundle installs from exhausting host disk

Installing an AI bundle on a tight-disk host could push the root
filesystem to zero bytes free after the preflight check had already
passed. Two root causes:

- move_tree used copytree+rmtree, so during the move the extracted
  payload existed in both staging and the venv at once -- a full
  transient doubling on disk. Rewrote it to rename entries (a cheap
  metadata op on the same filesystem, no copy), falling back to a copy
  only across filesystems.
- the preflight budget used the manifest's extractedSize verbatim, which
  is 0 for several archives, collapsing the estimate to just the
  compressed size. Added a conservative fallback (3x compressed) so a
  missing value can't under-reserve.

Also added a real-on-disk re-check immediately before the first
destructive venv write (measuring the actual extracted payload and
whether the move needs extra space for a cross-filesystem copy), which
also now covers the offline-import path that previously skipped the disk
check entirely; wrapped the moves so an out-of-space failure returns a
clean actionable error instead of a traceback; and made the disk check
resolve the nearest existing ancestor so it never throws on a
not-yet-created venv path.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* feat(web): show the real per-arch AI bundle download size

The bundle cards and install prompt showed a hardcoded, architecture-blind
estimatedSize string. That's misleading: amd64 hosts always pull the
CUDA-inclusive archive (there's no CPU-only amd64 variant published), so a
bundle labelled "1-2 GB" can actually download several times that, while
arm64 pulls a much smaller archive for the same label. The manifest
already carries the real per-arch compressedSize (and extractedSize where
measured), so surface those: a new optional downloadBytes/installedBytes
on FeatureBundleState, populated in getFeatureStates() for this host's
arch (resolver mirrors install_feature.py detect_arch), shown by the UI
when present with estimatedSize kept as the fallback label. Also nudged
upscale-enhance's fallback string (4-5 -> 5-6 GB) to match its real
compressed size, consistent with the earlier photo-restoration/ocr fixes.

Fields are optional so demo/mock and existing tests stay compiling; the
manifest's extractedSize is 0 for a few archives, which now surfaces as
null rather than a bogus 0.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(web): move the AI install queue to the server so it survives tab close

Installing multiple bundles could silently lose all but the first. The
server rejected a concurrent install with 409, so the client worked
around it by queueing the rest in browser-local state and only POSTing
each once it saw the previous finish. A single POSTed install is durable
(the installer child is detached from the request), but a queued one had
zero server footprint -- close the tab mid-queue and those installs
vanished with no error, while the UI still showed them "Queued". The
client "mutex" didn't even serialize: the queued bundles' local waits all
resolved at once and raced into concurrent POSTs that 409'd each other.

Now the queue lives on the server (a small in-memory FIFO leaf module).
The install endpoint enqueues instead of 409-ing and returns
202 {jobId, queued}; a pump starts the next bundle when the current one's
child exits (and after an offline import releases the lock), all behind
the existing venv + file locks, which are unchanged. The client just
POSTs every bundle immediately and reflects the server-reported
queued/installing status; Install All fires all POSTs and lets the server
serialize them, keeping the one-shot retry-on-failure. Adds "queued" to
FeatureStatus (the bundle card already rendered that state) and surfaces
it from getFeatureStates. In-memory is deliberate: it matches the
existing contract (survives a tab close, not a server restart, which
already clears the lock on boot).

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG

* fix(qa): don't log env-derived credentials in the AI-install script

CodeQL flagged clear-text logging of sensitive information: the login
status line interpolated the QA base URL and username (both read from
the process environment) into a console.log. Replaced with a static
message. QA helper only, but it's a real hygiene issue and cleared the
high-severity code-scanning alert on the PR.

Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
This commit is contained in:
SnapOtter
2026-07-03 09:54:02 +08:00
committed by GitHub
parent 4c32fee039
commit b37faed95f
81 changed files with 2466 additions and 758 deletions
+20 -1
View File
@@ -14,6 +14,25 @@ export function stripInternalPaths(message: string): string {
return message.replace(/\/(tmp|data|app|opt|home|workspace)\b[^\s'")}]*/g, "[internal]");
}
// Matching control characters is the entire point of these patterns (we strip
// them), so noControlCharactersInRegex's premise doesn't apply. ANSI_CSI =
// ESC [ ... final-byte (color/cursor/spinner); C0_CONTROLS = the C0 range + DEL,
// excluding tab (\x09) and newline (\x0A), which we keep.
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matches control chars to strip them
const ANSI_CSI = /\x1B\[[0-9;?]*[ -/]*[@-~]/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matches control chars to strip them
const C0_CONTROLS = /[\x00-\x08\x0B-\x1F\x7F]/g;
/**
* Strip ANSI escape sequences and other terminal control characters that
* subprocess CLIs (e.g. caire's progress spinner) emit into their stderr, so a
* surfaced tool-failure message is plain text rather than raw terminal garbage.
* Preserves tab and newline.
*/
export function stripControlChars(message: string): string {
return message.replace(ANSI_CSI, "").replace(C0_CONTROLS, "");
}
/**
* Unambiguous markers of a raw external-tool failure dump: the
* `ffmpeg/ffprobe exited N:` prefix that media-engine throws, a Python
@@ -33,7 +52,7 @@ const RAW_TOOL_FAILURE =
* sanitized. Idempotent, so it is safe to apply at every error surface.
*/
export function friendlyError(message: string): string {
const cleaned = stripInternalPaths(message);
const cleaned = stripInternalPaths(stripControlChars(message));
if (RAW_TOOL_FAILURE.test(cleaned) || cleaned.length > 280 || cleaned.split("\n").length > 3) {
return "Processing failed. The file may be in an unsupported or corrupted format.";
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Server-side feature-install queue (in-memory, FIFO).
*
* The queue lives on the server so a POST is always durable: the client POSTs
* immediately and the server serializes installs behind the existing file lock.
* A queued-but-not-yet-started install therefore has a real server footprint
* (this module), instead of only living in a browser tab that can close and
* silently drop it.
*
* This is a LEAF module: it imports NOTHING from feature-status.ts. The
* dependency runs the other way (feature-status.ts imports getQueuedBundleIds
* from here to surface "queued" status), so importing back would form a cycle.
*
* In-memory is deliberate, not a shortcut. The active install is a child of the
* API process and does not survive a server restart (recoverInterruptedInstalls
* clears the lock on boot because any previous install is dead). This queue
* matches that exact contract: it survives a browser tab close (the bug it
* fixes) but not a server restart.
*/
export interface QueuedInstall {
bundleId: string;
jobId: string;
}
let activeInstall: QueuedInstall | null = null;
const queue: QueuedInstall[] = [];
/** The bundle currently installing (holding the file lock), or null. */
export function getActiveBundleId(): string | null {
return activeInstall?.bundleId ?? null;
}
/** Bundle ids waiting in the queue, in FIFO order (excludes the active one). */
export function getQueuedBundleIds(): string[] {
return queue.map((entry) => entry.bundleId);
}
/** True when the bundle is currently installing or already waiting in the queue. */
export function isQueuedOrActive(bundleId: string): boolean {
return activeInstall?.bundleId === bundleId || queue.some((entry) => entry.bundleId === bundleId);
}
/**
* Add a bundle to the FIFO queue and return the effective jobId to track.
*
* Server-side dedup: if the bundle is already the active install or already in
* the queue, no second entry is added and the existing job's id is returned so
* the caller can attach to the in-flight progress stream.
*/
export function enqueue(entry: QueuedInstall): string {
if (activeInstall?.bundleId === entry.bundleId) {
return activeInstall.jobId;
}
const existing = queue.find((q) => q.bundleId === entry.bundleId);
if (existing) {
return existing.jobId;
}
queue.push(entry);
return entry.jobId;
}
/** Peek the head of the queue without removing it. */
export function peekQueue(): QueuedInstall | null {
return queue[0] ?? null;
}
/** Remove and return the head of the queue. */
export function dequeue(): QueuedInstall | null {
return queue.shift() ?? null;
}
/** Mark a bundle as the active install (called by the pump when it starts one). */
export function setActive(entry: QueuedInstall | null): void {
activeInstall = entry;
}
/** Clear the active install (called when the installer child exits). */
export function clearActive(): void {
activeInstall = null;
}
/** Test helper: wipe all queue state. */
export function resetQueueState(): void {
activeInstall = null;
queue.length = 0;
}
+31
View File
@@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
import type { FeatureBundleState, FeatureStatus } from "@snapotter/shared";
import { FEATURE_BUNDLES, getRequiredBundlesForTool } from "@snapotter/shared";
import * as tar from "tar";
import { getQueuedBundleIds } from "./feature-install-queue.js";
// ── Paths ───────────────────────────────────────────────────────────────
@@ -281,14 +282,30 @@ interface ManifestModel {
minSize?: number;
}
interface ManifestArchive {
compressedSize?: number;
extractedSize?: number;
}
interface ManifestBundle {
models: ManifestModel[];
archives?: Record<string, ManifestArchive>;
}
interface Manifest {
bundles: Record<string, ManifestBundle>;
}
/**
* Bundle archive key for this host, mirroring detect_arch() in
* install_feature.py exactly so the size we surface matches what actually gets
* downloaded. Only "amd64-gpu" and "arm64-cpu" archives are published; amd64
* always resolves to the GPU variant (there is no CPU-only amd64 archive).
*/
function bundleArchKey(): string {
return process.arch === "arm64" ? "arm64-cpu" : "amd64-gpu";
}
function readManifest(): Manifest | null {
if (!existsSync(MANIFEST_PATH)) return null;
try {
@@ -492,6 +509,9 @@ export function verifyBundleModels(bundleId: string): string | null {
export function getFeatureStates(): FeatureBundleState[] {
const installed = readInstalled();
const lock = getInstallingBundle();
const manifest = readManifest();
const arch = bundleArchKey();
const queuedIds = new Set(getQueuedBundleIds());
return Object.values(FEATURE_BUNDLES).map((bundle) => {
const installedBundle = installed.bundles[bundle.id];
@@ -517,11 +537,20 @@ export function getFeatureStates(): FeatureBundleState[] {
} else {
status = "installed";
}
} else if (queuedIds.has(bundle.id)) {
// Waiting behind the active install in the server-side queue.
status = "queued";
} else if (currentProgress?.bundleId === bundle.id && currentProgress.error) {
status = "error";
error = currentProgress.error;
}
const archive = manifest?.bundles[bundle.id]?.archives?.[arch];
const downloadBytes =
archive?.compressedSize && archive.compressedSize > 0 ? archive.compressedSize : null;
const installedBytes =
archive?.extractedSize && archive.extractedSize > 0 ? archive.extractedSize : null;
return {
id: bundle.id,
name: bundle.name,
@@ -529,6 +558,8 @@ export function getFeatureStates(): FeatureBundleState[] {
status,
installedVersion: installedBundle?.version ?? null,
estimatedSize: bundle.estimatedSize,
downloadBytes,
installedBytes,
enablesTools: bundle.enablesTools,
progress,
error,
+15
View File
@@ -5,9 +5,20 @@ import type { ToolProcessCtxV2 } from "../routes/tool-factory.js";
const EXT_VIDEO_CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".3gp": "video/3gpp",
".flv": "video/x-flv",
".wmv": "video/x-ms-wmv",
".mpg": "video/mpeg",
".mpeg": "video/mpeg",
".ts": "video/mp2t",
".mts": "video/mp2t",
".m2ts": "video/mp2t",
".ogv": "video/ogg",
};
/** Content type for a preserved-container video output; mp4 fallback. */
@@ -25,6 +36,9 @@ export function videoContentType(ext: string): string {
*/
export function videoEncodeArgsForContainer(ext: string): string[] {
const lower = ext.toLowerCase();
if (lower === ".mpg" || lower === ".mpeg") {
return ["-c:v", "mpeg2video", "-q:v", "4", "-pix_fmt", "yuv420p"];
}
if (lower === ".webm") {
return ["-c:v", resolveEncoder("vp9"), "-crf", "30", "-b:v", "0", "-row-mt", "1"];
}
@@ -44,6 +58,7 @@ export function videoEncodeArgsForContainer(ext: string): string[] {
*/
export function audioEncodeArgsForContainer(ext: string): string[] {
const lower = ext.toLowerCase();
if (lower === ".mpg" || lower === ".mpeg") return ["-c:a", "mp2", "-b:a", "192k"];
if (lower === ".webm") return ["-c:a", resolveEncoder("opus")];
if (lower === ".ogv" || lower === ".ogg") return ["-c:a", "libvorbis"];
return ["-c:a", resolveEncoder("aac")];
+13 -2
View File
@@ -6484,6 +6484,12 @@ paths:
Start async installation of an AI feature bundle. Downloads models and
configures the Python sidecar. Returns a jobId for progress tracking
via the SSE endpoint. Requires features:manage permission.
Installs are serialized server-side: a request made while another
install is running is queued (queued=true) rather than rejected, and
starts automatically when the running install finishes. A repeat
request for the same in-flight bundle is deduped and returns the same
jobId.
security:
- bearerAuth: []
parameters:
@@ -6495,7 +6501,7 @@ paths:
type: string
responses:
"202":
description: Installation started
description: Installation started or queued
content:
application/json:
schema:
@@ -6504,6 +6510,11 @@ paths:
jobId:
type: string
description: Job ID for tracking installation progress via SSE
queued:
type: boolean
description: >-
True when the install was queued behind another running
install instead of starting immediately.
"401":
description: Authentication required
content:
@@ -6523,7 +6534,7 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Bundle already installed or another install in progress
description: Bundle already installed
content:
application/json:
schema:
+21
View File
@@ -49,6 +49,24 @@ export async function verifyPassword(password: string, stored: string): Promise<
return timingSafeEqual(derived, storedBuf);
}
/**
* Fixed-cost hash used to equalize login timing for usernames that don't
* exist (or have no local password). Computed once per process and cached --
* without this, a login attempt for an unknown username returns as soon as
* the user lookup misses, while a wrong password for a real user waits on a
* full scrypt run. That gap is a timing side-channel an attacker can use to
* enumerate valid usernames even though both cases return an identical 401
* body. Running verifyPassword against this dummy hash pays the same scrypt
* cost on the "unknown user" path so the two cases are timing-indistinguishable.
*/
let dummyHashPromise: Promise<string> | null = null;
function getDummyHash(): Promise<string> {
if (!dummyHashPromise) {
dummyHashPromise = hashPassword(randomBytes(SALT_LENGTH).toString("hex"));
}
return dummyHashPromise;
}
/**
* Compute a fast lookup prefix for an API key.
* Uses SHA-256 (not scrypt) so lookups are O(1) instead of O(n).
@@ -358,6 +376,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}
if (!user?.passwordHash) {
// Pay the same scrypt cost a real password check would take, so
// response timing doesn't reveal whether the username exists.
await verifyPassword(body.password, await getDummyHash());
authAttempts.inc({ method: "password", result: "failure" });
await audit("LOGIN_FAILED", {
username: sanitizeAuditInput(body.username),
+196 -138
View File
@@ -24,6 +24,14 @@ import { acquireVenvLock, shutdownDispatcher } from "@snapotter/ai";
import { ANALYTICS_EVENTS, FEATURE_BUNDLES } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { trackEvent } from "../lib/analytics.js";
import {
clearActive,
dequeue,
enqueue,
getActiveBundleId,
peekQueue,
setActive,
} from "../lib/feature-install-queue.js";
import {
acquireInstallLock,
getAiDir,
@@ -49,6 +57,173 @@ import { updateSingleFileProgress } from "./progress.js";
const venvPath = process.env.PYTHON_VENV_PATH || "/opt/venv";
const pythonPath = `${venvPath}/bin/python3`;
/**
* Spawn the installer child for a bundle and wire up progress / analytics /
* error reporting. The child is detached from any HTTP request, so it finalizes
* via its own close/error handlers regardless of the connection. On exit it
* releases the venv + file locks, clears the active slot, and pumps the queue
* so the next waiting bundle starts automatically.
*
* Precondition: the file install lock is already held for `bundleId` and the
* queue's active slot is already set to it (pump() does both before calling).
*/
function startInstall(bundleId: string, jobId: string): void {
const scriptPath = getInstallScriptPath();
const manifestPath = getManifestPath();
const modelsDir = getModelsDir();
const installStartTime = Date.now();
void (async () => {
// Hold the venv lock across the whole install so no AI tool job loads
// native libs from the venv while pip is rewriting them (that segfaults
// the sidecar). This awaits any in-flight AI job before the installer
// starts; the lock is released when the installer process exits.
const releaseVenv = await acquireVenvLock();
let venvReleased = false;
const releaseVenvOnce = () => {
if (!venvReleased) {
venvReleased = true;
releaseVenv();
}
};
const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
BUNDLE_ID: bundleId,
PIP_CACHE_DIR: join(getAiDir(), "pip-cache"),
},
});
let stderrBuffer = "";
let stdoutBuffer = "";
const lastStderrLines: string[] = [];
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBuffer += chunk.toString();
const lines = stderrBuffer.split("\n");
stderrBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
lastStderrLines.push(trimmed);
if (lastStderrLines.length > 20) lastStderrLines.shift();
try {
const parsed = JSON.parse(trimmed) as { progress?: number; stage?: string };
if (typeof parsed.progress === "number") {
setInstallProgress(
bundleId,
{ percent: parsed.progress, stage: parsed.stage ?? "" },
null,
);
updateSingleFileProgress({
jobId,
phase: "processing",
percent: parsed.progress,
stage: parsed.stage,
});
}
} catch {
// Not JSON progress - rembg/pip output noise, keep in lastStderrLines for error reporting
}
}
});
child.on("close", (code) => {
releaseVenvOnce();
releaseInstallLock();
clearActive();
pump();
if (code === 0) {
invalidateCache();
shutdownDispatcher();
setInstallProgress(null, null, null);
updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" });
trackEvent(ANALYTICS_EVENTS.AI_BUNDLE_ACTION, {
bundle_id: bundleId,
action: "installed",
duration_ms: Date.now() - installStartTime,
});
} else {
// Extract the structured error from Python's fail() function first.
// fail() writes {"error": "..."} to stderr - prefer this over raw lines.
let errorMsg: string | undefined;
for (let i = lastStderrLines.length - 1; i >= 0; i--) {
const line = lastStderrLines[i];
if (line.startsWith("{")) {
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
if (typeof parsed.error === "string") {
errorMsg = parsed.error;
break;
}
} catch {
// Not valid JSON
}
}
}
if (!errorMsg) {
if (code === 137) {
errorMsg =
"Installation was killed due to insufficient memory. " +
"Try increasing the container's memory limit (e.g. mem_limit: 6g in docker-compose.yml) and retry.";
} else {
const meaningful = lastStderrLines.filter(
(l) =>
!l.startsWith("{") &&
!l.includes("pthread_setaffinity_np") &&
!l.includes("\x1b[") &&
!l.includes("━") &&
!/^\s*\d+%\|/.test(l),
);
errorMsg =
meaningful.join("\n") ||
stdoutBuffer.trim() ||
`Install failed with exit code ${code}`;
}
}
setInstallProgress(bundleId, null, errorMsg);
updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg });
}
});
child.on("error", (err) => {
releaseVenvOnce();
releaseInstallLock();
clearActive();
pump();
const errorMsg = `Failed to spawn install process: ${err.message}`;
setInstallProgress(bundleId, null, errorMsg);
updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg });
});
})();
}
/**
* Start the next queued install if nothing is running and the file lock is
* free. If the lock is held (an offline import is in progress) the head stays
* queued and gets pumped again when the import releases the lock.
*/
function pump(): void {
if (getActiveBundleId()) return;
const head = peekQueue();
if (!head) return;
if (!acquireInstallLock(head.bundleId)) return;
dequeue();
setActive(head);
startInstall(head.bundleId, head.jobId);
}
interface BundleIdParams {
bundleId: string;
}
@@ -122,6 +297,10 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
status: "installed" as const,
installedVersion: null,
estimatedSize: bundle.estimatedSize,
// Native (non-Docker) mode runs the models in-process with no bundle
// archive to download, so there are no per-arch download/on-disk sizes.
downloadBytes: null,
installedBytes: null,
enablesTools: bundle.enablesTools,
progress: null,
error: null,
@@ -155,147 +334,22 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
markUninstalled(bundleId);
}
if (!acquireInstallLock(bundleId)) {
return reply.status(409).send({ error: "Another install is already in progress" });
}
// Queue the install on the server so the POST is durable: enqueue()
// dedups an already-active/queued bundle and returns the effective jobId,
// then pump() starts it immediately if nothing else is installing. A
// bundle that lands behind another install stays queued server-side and
// starts automatically when the running install finishes.
const jobId = crypto.randomUUID();
const scriptPath = getInstallScriptPath();
const manifestPath = getManifestPath();
const modelsDir = getModelsDir();
const effectiveJobId = enqueue({ bundleId, jobId });
pump();
const installStartTime = Date.now();
// queued === true means it did NOT start right now (another install is
// active, or an offline import holds the lock). The client still opens
// the SSE stream for the returned jobId; the progress route tolerates a
// not-yet-started jobId and streams once the installer begins.
const queued = getActiveBundleId() !== bundleId;
// Hold the venv lock across the whole install so no AI tool job loads
// native libs from the venv while pip is rewriting them (that segfaults
// the sidecar). This awaits any in-flight AI job before the installer
// starts; the lock is released when the installer process exits.
const releaseVenv = await acquireVenvLock();
let venvReleased = false;
const releaseVenvOnce = () => {
if (!venvReleased) {
venvReleased = true;
releaseVenv();
}
};
const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
BUNDLE_ID: bundleId,
PIP_CACHE_DIR: join(getAiDir(), "pip-cache"),
},
});
let stderrBuffer = "";
let stdoutBuffer = "";
const lastStderrLines: string[] = [];
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBuffer += chunk.toString();
const lines = stderrBuffer.split("\n");
stderrBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
lastStderrLines.push(trimmed);
if (lastStderrLines.length > 20) lastStderrLines.shift();
try {
const parsed = JSON.parse(trimmed) as { progress?: number; stage?: string };
if (typeof parsed.progress === "number") {
setInstallProgress(
bundleId,
{ percent: parsed.progress, stage: parsed.stage ?? "" },
null,
);
updateSingleFileProgress({
jobId,
phase: "processing",
percent: parsed.progress,
stage: parsed.stage,
});
}
} catch {
// Not JSON progress - rembg/pip output noise, keep in lastStderrLines for error reporting
}
}
});
child.on("close", (code) => {
releaseVenvOnce();
releaseInstallLock();
if (code === 0) {
invalidateCache();
shutdownDispatcher();
setInstallProgress(null, null, null);
updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" });
trackEvent(ANALYTICS_EVENTS.AI_BUNDLE_ACTION, {
bundle_id: bundleId,
action: "installed",
duration_ms: Date.now() - installStartTime,
});
} else {
// Extract the structured error from Python's fail() function first.
// fail() writes {"error": "..."} to stderr - prefer this over raw lines.
let errorMsg: string | undefined;
for (let i = lastStderrLines.length - 1; i >= 0; i--) {
const line = lastStderrLines[i];
if (line.startsWith("{")) {
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
if (typeof parsed.error === "string") {
errorMsg = parsed.error;
break;
}
} catch {
// Not valid JSON
}
}
}
if (!errorMsg) {
if (code === 137) {
errorMsg =
"Installation was killed due to insufficient memory. " +
"Try increasing the container's memory limit (e.g. mem_limit: 6g in docker-compose.yml) and retry.";
} else {
const meaningful = lastStderrLines.filter(
(l) =>
!l.startsWith("{") &&
!l.includes("pthread_setaffinity_np") &&
!l.includes("\x1b[") &&
!l.includes("━") &&
!/^\s*\d+%\|/.test(l),
);
errorMsg =
meaningful.join("\n") ||
stdoutBuffer.trim() ||
`Install failed with exit code ${code}`;
}
}
setInstallProgress(bundleId, null, errorMsg);
updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg });
}
});
child.on("error", (err) => {
releaseVenvOnce();
releaseInstallLock();
const errorMsg = `Failed to spawn install process: ${err.message}`;
setInstallProgress(bundleId, null, errorMsg);
updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg });
});
return reply.status(202).send({ jobId });
return reply.status(202).send({ jobId: effectiveJobId, queued });
},
);
@@ -426,6 +480,10 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
return reply.status(400).send({ error: err.message });
}
throw err;
} finally {
// The import releases the file lock in its own finally; pump here so a
// bundle that was queued while the import held the lock starts now.
pump();
}
},
);
+46
View File
@@ -243,6 +243,52 @@ function getContentType(ext: string): string {
zip: "application/zip",
ico: "image/x-icon",
json: "application/json",
csv: "text/csv",
tsv: "text/tab-separated-values",
txt: "text/plain",
md: "text/markdown",
markdown: "text/markdown",
html: "text/html",
htm: "text/html",
xml: "application/xml",
yaml: "application/yaml",
yml: "application/yaml",
mp4: "video/mp4",
m4v: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
"3gp": "video/3gpp",
flv: "video/x-flv",
wmv: "video/x-ms-wmv",
mpg: "video/mpeg",
mpeg: "video/mpeg",
ts: "video/mp2t",
mts: "video/mp2t",
m2ts: "video/mp2t",
ogv: "video/ogg",
mp3: "audio/mpeg",
wav: "audio/wav",
flac: "audio/flac",
aac: "audio/aac",
m4a: "audio/mp4",
ogg: "audio/ogg",
opus: "audio/opus",
wma: "audio/x-ms-wma",
aiff: "audio/aiff",
amr: "audio/amr",
ac3: "audio/ac3",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
rtf: "application/rtf",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
odp: "application/vnd.oasis.opendocument.presentation",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ods: "application/vnd.oasis.opendocument.spreadsheet",
epub: "application/epub+zip",
srt: "application/x-subrip",
vtt: "text/vtt",
jxl: "image/jxl",
dng: "image/x-adobe-dng",
cr2: "image/x-canon-cr2",
+6 -7
View File
@@ -21,7 +21,7 @@ import { getObjectBuffer, putObject } from "../lib/object-storage.js";
import { resolveToolPool, shouldSkipSyncWindow } from "../lib/pool.js";
import { getSettingNumber } from "../lib/settings-helpers.js";
import { type ReceivedUpload, receiveUpload } from "../lib/upload-stream.js";
import { InputValidationError } from "../modality/contract.js";
import { type InputHandler, InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.js";
import { requireToolAccess } from "../permissions.js";
@@ -384,16 +384,15 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
}
}
// Build per-position input handlers: when inputKinds is present,
// each position gets a MediaInputHandler for its kind; otherwise
// the tool's modality drives a single shared handler as before.
const kindHandlers: Map<MediaInputKind, MediaInputHandler> = new Map();
function handlerForPosition(idx: number) {
// Build per-position input handlers. Mixed-input image slots still need
// the image pipeline so RAW/HEIC/SVG inputs are normalized before jobs.
const kindHandlers: Map<MediaInputKind, InputHandler> = new Map();
function handlerForPosition(idx: number): InputHandler {
if (config.inputKinds) {
const kind = config.inputKinds[Math.min(idx, config.inputKinds.length - 1)];
let h = kindHandlers.get(kind);
if (!h) {
h = new MediaInputHandler(kind);
h = kind === "image" ? inputHandlerFor("image") : new MediaInputHandler(kind);
kindHandlers.set(kind, h);
}
return h;
+22 -84
View File
@@ -1,12 +1,11 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { autoOrient } from "../../lib/auto-orient.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { InputValidationError } from "../../modality/contract.js";
import { inputHandlerFor } from "../../modality/input-handler.js";
/**
* Compare two images: compute a pixel-level diff and similarity score.
@@ -15,6 +14,8 @@ export function registerCompare(app: FastifyInstance) {
app.post("/api/v1/tools/image/compare", async (request, reply) => {
let bufferA: Buffer | null = null;
let bufferB: Buffer | null = null;
let filenameA = "first.png";
let filenameB = "second.png";
try {
const parts = request.parts();
@@ -27,8 +28,10 @@ export function registerCompare(app: FastifyInstance) {
const buf = Buffer.concat(chunks);
if (!bufferA) {
bufferA = buf;
filenameA = sanitizeFilename(part.filename ?? filenameA);
} else {
bufferB = buf;
filenameB = sanitizeFilename(part.filename ?? filenameB);
}
}
}
@@ -44,85 +47,17 @@ export function registerCompare(app: FastifyInstance) {
}
try {
const valA = await validateImageBuffer(bufferA, "image");
if (!valA.valid) {
return reply.status(400).send({ error: `Invalid first image: ${valA.reason}` });
}
if (valA.format === "heif") {
try {
bufferA = await decodeHeic(bufferA);
} catch (err) {
return reply.status(422).send({
error: "Failed to decode first image (HEIC). Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err),
});
}
}
if (needsCliDecode(valA.format)) {
try {
bufferA = await decodeToSharpCompat(bufferA, valA.format);
} catch {
try {
await sharp(bufferA).metadata();
} catch (err) {
return reply.status(422).send({
error: `Failed to decode first image (${valA.format.toUpperCase()})`,
details: err instanceof Error ? err.message : String(err),
});
}
}
}
if (valA.format === "svg") {
try {
bufferA = decompressSvgz(bufferA);
bufferA = sanitizeSvg(bufferA);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG (first image)",
});
}
}
bufferA = await autoOrient(bufferA);
const valB = await validateImageBuffer(bufferB, "image");
if (!valB.valid) {
return reply.status(400).send({ error: `Invalid second image: ${valB.reason}` });
}
if (valB.format === "heif") {
try {
bufferB = await decodeHeic(bufferB);
} catch (err) {
return reply.status(422).send({
error: "Failed to decode second image (HEIC). Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err),
});
}
}
if (needsCliDecode(valB.format)) {
try {
bufferB = await decodeToSharpCompat(bufferB, valB.format);
} catch {
try {
await sharp(bufferB).metadata();
} catch (err) {
return reply.status(422).send({
error: `Failed to decode second image (${valB.format.toUpperCase()})`,
details: err instanceof Error ? err.message : String(err),
});
}
}
}
if (valB.format === "svg") {
try {
bufferB = decompressSvgz(bufferB);
bufferB = sanitizeSvg(bufferB);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG (second image)",
});
}
}
bufferB = await autoOrient(bufferB);
const imageHandler = inputHandlerFor("image");
bufferA = (
await imageHandler.prepare(bufferA, filenameA, {
scratchDir: tmpdir(),
})
).buffer;
bufferB = (
await imageHandler.prepare(bufferB, filenameB, {
scratchDir: tmpdir(),
})
).buffer;
// Normalize both to same size for comparison
const metaA = await sharp(bufferA).metadata();
@@ -189,6 +124,9 @@ export function registerCompare(app: FastifyInstance) {
processedSize: diffBuffer.length,
});
} catch (err) {
if (err instanceof InputValidationError) {
return reply.status(err.statusCode).send({ error: err.message, details: err.details });
}
return reply.status(422).send({
error: "Comparison failed",
details: err instanceof Error ? err.message : "Unknown error",
+49 -31
View File
@@ -1,35 +1,14 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import type { FastifyInstance } from "fastify";
import sharp, { type Blend } 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 { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
async function decodeBuffer(inputBuffer: Buffer, filename: string): Promise<Buffer> {
const validation = await validateImageBuffer(inputBuffer, filename);
if (!validation.valid) {
throw new Error(`Invalid image: ${validation.reason}`);
}
let decoded = inputBuffer;
if (validation.format === "heif") {
decoded = await decodeHeic(decoded);
} else if (needsCliDecode(validation.format)) {
const ext = filename.split(".").pop()?.toLowerCase();
decoded = await decodeToSharpCompat(decoded, validation.format, ext);
} else if (validation.format === "svg") {
decoded = decompressSvgz(decoded);
decoded = sanitizeSvg(decoded);
}
return autoOrient(decoded);
}
import { resolveOutputFormat } from "../../lib/output-format.js";
import { InputValidationError } from "../../modality/contract.js";
import { inputHandlerFor } from "../../modality/input-handler.js";
const settingsSchema = z.object({
x: z.number().min(0).default(0),
@@ -108,8 +87,27 @@ export function registerCompose(app: FastifyInstance) {
}
try {
baseBuffer = await decodeBuffer(baseBuffer, filename);
overlayBuffer = await decodeBuffer(overlayBuffer, overlayFilename);
const imageHandler = inputHandlerFor("image");
const base = await imageHandler.prepare(baseBuffer, filename, {
scratchDir: tmpdir(),
});
const overlay = await imageHandler.prepare(overlayBuffer, overlayFilename, {
scratchDir: tmpdir(),
});
baseBuffer = base.buffer;
overlayBuffer = overlay.buffer;
filename = base.filename;
const left = Math.floor(settings.x);
const top = Math.floor(settings.y);
const baseMeta = await sharp(baseBuffer).metadata();
const baseWidth = baseMeta.width ?? 0;
const baseHeight = baseMeta.height ?? 0;
const availableWidth = baseWidth - left;
const availableHeight = baseHeight - top;
if (availableWidth <= 0 || availableHeight <= 0) {
throw new InputValidationError("Overlay position is outside the base image");
}
// Apply opacity to overlay if needed
let processedOverlay = overlayBuffer;
@@ -136,27 +134,47 @@ export function registerCompose(app: FastifyInstance) {
.toBuffer();
}
const overlayMeta = await sharp(processedOverlay).metadata();
const overlayWidth = overlayMeta.width ?? 0;
const overlayHeight = overlayMeta.height ?? 0;
if (overlayWidth > availableWidth || overlayHeight > availableHeight) {
processedOverlay = await sharp(processedOverlay)
.extract({
left: 0,
top: 0,
width: Math.min(overlayWidth, availableWidth),
height: Math.min(overlayHeight, availableHeight),
})
.toBuffer();
}
const outputFormat = await resolveOutputFormat(baseBuffer, filename);
const result = await sharp(baseBuffer)
.composite([
{
input: processedOverlay,
top: settings.y,
left: settings.x,
top,
left,
blend: settings.blendMode as Blend,
},
])
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
const jobId = randomUUID();
await putObject(`outputs/${jobId}/${filename}`, result);
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_composed.${outputFormat.extension}`;
await putObject(`outputs/${jobId}/${outputFilename}`, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: baseBuffer.length,
processedSize: result.length,
});
} catch (err) {
if (err instanceof InputValidationError) {
return reply.status(err.statusCode).send({ error: err.message, details: err.details });
}
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
@@ -6,7 +6,7 @@ import { seamCarve } from "@snapotter/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 { formatZodErrors, friendlyError } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
@@ -73,7 +73,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
} catch (err) {
return reply.status(422).send({
error: "Failed to decode HEIC/HEIF file",
details: err instanceof Error ? err.message : String(err),
details: friendlyError(err instanceof Error ? err.message : String(err)),
});
}
}
@@ -87,7 +87,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
} catch (err) {
return reply.status(422).send({
error: `Failed to decode ${validation.format} file`,
details: err instanceof Error ? err.message : String(err),
details: friendlyError(err instanceof Error ? err.message : String(err)),
});
}
}
@@ -164,7 +164,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
request.log.error({ err, toolId: "content-aware-resize" }, "Content-aware resize failed");
return reply.status(422).send({
error: "Content-aware resize failed",
details: err instanceof Error ? err.message : "Unknown error",
details: friendlyError(err instanceof Error ? err.message : "Unknown error"),
});
}
},
@@ -4,7 +4,6 @@ import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
@@ -28,14 +27,18 @@ export function registerConvertDocument(app: FastifyInstance) {
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
ctx.report(90, "Done");
return {
buffer: input.buffer,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
@@ -4,7 +4,6 @@ import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
@@ -26,14 +25,18 @@ export function registerConvertPresentation(app: FastifyInstance) {
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
ctx.report(90, "Done");
return {
buffer: input.buffer,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
@@ -4,7 +4,6 @@ import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
@@ -27,14 +26,18 @@ export function registerConvertSpreadsheet(app: FastifyInstance) {
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
ctx.report(90, "Done");
return {
buffer: input.buffer,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
+16 -4
View File
@@ -2,7 +2,13 @@ import { basename, extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
import {
audioEncodeArgsForContainer,
runFfmpegWithProgress,
stageMediaInputs,
videoContentType,
videoEncodeArgsForContainer,
} from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
@@ -34,6 +40,7 @@ export function registerEmbedSubtitles(app: FastifyInstance) {
const toMp4 = [".mp4", ".mov", ".m4v"].includes(srcExt);
const outExt = toMp4 ? ".mp4" : ".mkv";
const scodec = toMp4 ? "mov_text" : "srt";
const reencodeInputStreams = outExt === ".mkv" && [".mpg", ".mpeg"].includes(srcExt);
const outName = `${base}_subs${outExt}`;
const contentType = videoContentType(outExt);
@@ -47,16 +54,21 @@ export function registerEmbedSubtitles(app: FastifyInstance) {
await runFfmpegWithProgress(
ctx,
[
"-fflags",
"+genpts",
"-i",
videoPath,
"-i",
subPath,
"-map",
"0",
"0:v:0",
"-map",
"0:a?",
"-map",
"1:0",
"-c",
"copy",
...(reencodeInputStreams
? [...videoEncodeArgsForContainer(outExt), ...audioEncodeArgsForContainer(outExt)]
: ["-c:v", "copy", "-c:a", "copy"]),
"-c:s",
scodec,
"-metadata:s:s:0",
+18 -3
View File
@@ -1,10 +1,12 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { resolveEncoder } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
import { runFfmpegWithProgress } from "../../lib/media-tool.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
import { createToolRoute, type ToolProcessCtxV2 } from "../tool-factory.js";
const DIMS: Record<string, { w: number; h: number }> = {
"1080p": { w: 1920, h: 1080 },
@@ -35,7 +37,7 @@ export function registerImagesToVideo(app: FastifyInstance) {
const settings = settingsSchema.parse(ctx.settings);
const { w: W, h: H } = DIMS[settings.resolution];
const paths = await stageMediaInputs(ctx);
const paths = await stageImageFrames(ctx);
const parts: string[] = [];
const refs: string[] = [];
@@ -77,3 +79,16 @@ export function registerImagesToVideo(app: FastifyInstance) {
},
});
}
async function stageImageFrames(ctx: ToolProcessCtxV2): Promise<string[]> {
const dir = join(ctx.scratchDir, "media", "frames");
await mkdir(dir, { recursive: true });
return Promise.all(
ctx.inputs.map(async (input, index) => {
const framePath = join(dir, `frame-${String(index).padStart(4, "0")}.png`);
await sharp(input.buffer, { animated: false }).png().toFile(framePath);
return framePath;
}),
);
}
+17 -2
View File
@@ -41,9 +41,8 @@ export function registerReplaceAudio(app: FastifyInstance) {
throw new InputValidationError("Second file must be an audio file");
}
// vp8/vp9 in webm cannot be muxed into mp4; keep the source container
const vcodec = videoProbe.streams.find((s) => s.type === "video")?.codec ?? "";
const ext = ["vp8", "vp9"].includes(vcodec) ? ".webm" : ".mp4";
const ext = outputExtForCopiedVideo(vcodec);
const videoBase = basename(ctx.inputs[0].filename, extname(ctx.inputs[0].filename));
const outName = `${videoBase}_newaudio${ext}`;
const outPath = join(ctx.scratchDir, "media", outName);
@@ -75,3 +74,19 @@ export function registerReplaceAudio(app: FastifyInstance) {
},
});
}
function outputExtForCopiedVideo(codec: string): ".mp4" | ".webm" | ".mkv" | ".mpg" {
if (["mpeg1video", "mpeg2video"].includes(codec)) {
return ".mpg";
}
if (["vp8", "vp9"].includes(codec)) {
return ".webm";
}
if (["h264", "hevc", "mpeg4", "av1"].includes(codec)) {
return ".mp4";
}
return ".mkv";
}
+12 -46
View File
@@ -1,17 +1,15 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { vectorize as vtrace } from "@neplex/vectorizer";
import type { FastifyInstance } from "fastify";
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 { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { InputValidationError } from "../../modality/contract.js";
import { inputHandlerFor } from "../../modality/input-handler.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -112,7 +110,7 @@ export function registerVectorize(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "output").replace(/\.[^.]+$/, "");
filename = sanitizeFilename(part.filename ?? "output");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
@@ -143,46 +141,11 @@ export function registerVectorize(app: FastifyInstance) {
}
try {
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
if (validation.format === "heif") {
try {
fileBuffer = await decodeHeic(fileBuffer);
} catch (err) {
return reply.status(422).send({
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err),
});
}
}
if (needsCliDecode(validation.format)) {
try {
const fileExt = filename.split(".").pop()?.toLowerCase();
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
} catch {
try {
await sharp(fileBuffer).metadata();
} catch (err) {
return reply.status(422).send({
error: `Failed to decode ${validation.format.toUpperCase()} file`,
details: err instanceof Error ? err.message : String(err),
});
}
}
}
if (validation.format === "svg") {
try {
fileBuffer = decompressSvgz(fileBuffer);
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG",
});
}
}
fileBuffer = await autoOrient(fileBuffer);
const prepared = await inputHandlerFor("image").prepare(fileBuffer, filename, {
scratchDir: tmpdir(),
});
fileBuffer = prepared.buffer;
filename = prepared.filename;
const result = await vectorizeBuffer(fileBuffer, settings, filename);
@@ -196,6 +159,9 @@ export function registerVectorize(app: FastifyInstance) {
processedSize: result.buffer.length,
});
} catch (err) {
if (err instanceof InputValidationError) {
return reply.status(err.statusCode).send({ error: err.message, details: err.details });
}
return reply.status(422).send({
error: "Vectorization failed",
details: err instanceof Error ? err.message : "Unknown error",
+6 -2
View File
@@ -9,6 +9,7 @@ import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
const settingsSchema = z.object({
@@ -224,16 +225,19 @@ export function registerWatermarkImage(app: FastifyInstance) {
break;
}
const outputFormat = await resolveOutputFormat(mainBuffer, filename);
const result = await sharp(mainBuffer)
.composite([{ input: wmBuffer, top, left }])
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
const jobId = randomUUID();
await putObject(`outputs/${jobId}/${filename}`, result);
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_watermarked.${outputFormat.extension}`;
await putObject(`outputs/${jobId}/${outputFilename}`, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: mainBuffer.length,
processedSize: result.length,
});
+13
View File
@@ -48,6 +48,19 @@ export default defineConfig({
},
vite: {
build: {
rollupOptions: {
onwarn(warning, defaultHandler) {
if (
warning.code === "INVALID_ANNOTATION" &&
warning.id?.includes("@vueuse/core/dist/index.js")
) {
return;
}
defaultHandler(warning);
},
},
},
plugins: [
pagefindPlugin({
btnPlaceholder: "Search",
+1 -1
View File
@@ -487,7 +487,7 @@ labels:
### Caddy
```caddyfile
```txt
images.example.com {
reverse_proxy localhost:1349 {
flush_interval -1
+2 -14
View File
@@ -1,5 +1,5 @@
import { AlertCircle, FileImage, FileUp, Upload } from "lucide-react";
import { type DragEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
import { type DragEvent, useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useUrlImport } from "@/hooks/use-url-import";
import { cn } from "@/lib/utils";
@@ -195,16 +195,6 @@ export function Dropzone({
input.click();
}, [multiple, resolvedAccept, checkFile, onFiles, acceptDescription, accept]);
const handleDropzoneKeyDown = useCallback(
(e: KeyboardEvent<HTMLElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleClick();
},
[handleClick],
);
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const clip = e.clipboardData;
@@ -243,10 +233,8 @@ export function Dropzone({
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
onKeyDown={handleDropzoneKeyDown}
className={cn(
"group flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-all duration-200 mx-auto max-w-2xl w-full cursor-pointer",
"group flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-all duration-200 mx-auto max-w-2xl w-full",
compact ? "min-h-0 h-full" : "min-h-[400px]",
isDragging
? "border-primary bg-primary/10 scale-[1.01]"
@@ -2,7 +2,7 @@ import type { FeatureBundleState } from "@snapotter/shared";
import { AlertCircle, Clock, Download, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { format } from "@/lib/format";
import { format, formatFileSize } from "@/lib/format";
import { useFeaturesStore } from "@/stores/features-store";
const PROGRESS_MESSAGES = [
@@ -125,7 +125,11 @@ export function FeatureInstallPrompt({
</p>
{!isRepair && (
<p className="text-sm text-muted-foreground">
{format(t.features.requiresDownload, { size: bundle.estimatedSize })}
{format(t.features.requiresDownload, {
size: bundle.downloadBytes
? formatFileSize(bundle.downloadBytes)
: bundle.estimatedSize,
})}
</p>
)}
</div>
+1 -5
View File
@@ -13,7 +13,6 @@ import {
import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useMobile } from "@/hooks/use-mobile";
import { useTheme } from "@/hooks/use-theme";
import { cn } from "@/lib/utils";
@@ -62,7 +61,6 @@ export function TopNav({
}: TopNavProps) {
const location = useLocation();
const isMobile = useMobile();
const { authEnabled } = useAuth();
const { t } = useTranslation();
const navLinks = useNavLinks();
@@ -282,9 +280,7 @@ export function TopNav({
<HelpCircle className="h-4 w-4" />
</button>
{!isMobile && authEnabled && (
<AvatarDropdown onSettingsClick={onSettingsClick} variant={variant} />
)}
{!isMobile && <AvatarDropdown onSettingsClick={onSettingsClick} variant={variant} />}
</div>
</header>
);
@@ -283,7 +283,9 @@ function BundleCard({
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{bundle.name}</p>
<p className="text-xs text-muted-foreground">
{bundle.description} (~{bundle.estimatedSize})
{bundle.description} (~
{bundle.downloadBytes ? formatFileSize(bundle.downloadBytes) : bundle.estimatedSize}
{bundle.installedBytes ? `, ${formatFileSize(bundle.installedBytes)} on disk` : ""})
</p>
</div>
<div className="flex items-center gap-3 shrink-0 ms-4">
@@ -215,25 +215,6 @@ export function FindDuplicatesSettings() {
URL.revokeObjectURL(url);
}, [files, results]);
const _handleDownloadAll = useCallback(async () => {
const { zipSync } = await import("fflate");
const zipData: Record<string, Uint8Array> = {};
for (const file of files) {
const buf = await file.arrayBuffer();
zipData[file.name] = new Uint8Array(buf);
}
const zipped = zipSync(zipData);
const blob = new Blob([zipped as Uint8Array<ArrayBuffer>], { type: "application/zip" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "all-images.zip";
a.click();
URL.revokeObjectURL(url);
}, [files]);
const hasFiles = files.length >= 2;
const activeDesc = preset ? PRESET_DESCRIPTIONS[preset] : null;
@@ -90,11 +90,12 @@ export function InfoSettings() {
[setProcessing, setError, t],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: files.length is the reset trigger
useEffect(() => {
cacheRef.current.clear();
autoFetchRef.current = false;
setInfo(null);
}, []);
}, [files.length]);
useEffect(() => {
if (!autoFetchRef.current || files.length === 0) return;
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { formatHeaders } from "@/lib/api";
import { clearToken, formatHeaders } from "@/lib/api";
import { useConnectionStore } from "@/stores/connection-store";
interface AuthState {
@@ -110,7 +110,7 @@ export function useAuth() {
hasLocalPassword: session.user?.hasLocalPassword ?? false,
});
} else {
localStorage.removeItem("snapotter-token");
clearToken();
if (!cancelled)
setState({
loading: false,
+7 -3
View File
@@ -45,9 +45,13 @@ export function parseApiError(
// ── Auth Headers ───────────────────────────────────────────────
function getBrowserStorage(): Storage | null {
return typeof window !== "undefined" ? window.localStorage : null;
}
function getToken(): string {
try {
return localStorage.getItem("snapotter-token") || "";
return getBrowserStorage()?.getItem("snapotter-token") || "";
} catch {
return "";
}
@@ -152,11 +156,11 @@ export async function apiDelete<T>(path: string): Promise<T> {
}
export function setToken(token: string) {
localStorage.setItem("snapotter-token", token);
getBrowserStorage()?.setItem("snapotter-token", token);
}
export function clearToken() {
localStorage.removeItem("snapotter-token");
getBrowserStorage()?.removeItem("snapotter-token");
}
// ── File Upload / Download ──────────────────────────────────────
-1
View File
@@ -168,7 +168,6 @@ export function LoginPage() {
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const _data = await res.json().catch(() => ({}));
setError(t.auth.invalidCredentials);
return;
}
+116 -121
View File
@@ -44,14 +44,11 @@ interface FeaturesState {
export const useFeaturesStore = create<FeaturesState>((set, get) => {
const esRefs: Record<string, EventSource> = {};
const pollRefs: Record<string, ReturnType<typeof setInterval>> = {};
const completionRefs: Record<string, () => void> = {};
const resolveCompletion = (bundleId: string) => {
if (completionRefs[bundleId]) {
completionRefs[bundleId]();
delete completionRefs[bundleId];
}
};
// Bundles that already got one retry during the current Install All run.
// Preserves the old one-shot retry-on-failure behavior now that the server
// serializes installs and we no longer drive the queue from the client.
const installAllRetried = new Set<string>();
const refreshBundles = async () => {
try {
@@ -60,36 +57,82 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
} catch {}
};
/** Drop a bundle from both the installing map and the queued pill. */
const stopTracking = (bundleId: string) => {
const installing = { ...get().installing };
delete installing[bundleId];
set({ installing, queued: get().queued.filter((id) => id !== bundleId) });
};
/** Clear Install All once every bundle it kicked off has drained. */
const maybeFinishInstallAll = () => {
if (
get().installAllActive &&
Object.keys(get().installing).length === 0 &&
get().queued.length === 0
) {
installAllRetried.clear();
set({ installAllActive: false });
}
};
/**
* A bundle install reached a terminal state. During Install All a failure is
* retried once (re-POSTed); otherwise the error is recorded. Either way we
* check whether the Install All run is done.
*/
const onInstallSettled = (bundleId: string, errorMsg: string | null) => {
if (errorMsg) {
if (get().installAllActive && !installAllRetried.has(bundleId)) {
installAllRetried.add(bundleId);
const errors = { ...get().errors };
delete errors[bundleId];
set({ errors });
get().installBundle(bundleId);
return;
}
set({ errors: { ...get().errors, [bundleId]: errorMsg } });
}
maybeFinishInstallAll();
};
const startPolling = (bundleId: string) => {
if (pollRefs[bundleId]) return;
pollRefs[bundleId] = setInterval(async () => {
try {
await refreshBundles();
const updated = get().bundles.find((b) => b.id === bundleId);
if (updated?.status !== "installing") {
clearInterval(pollRefs[bundleId]);
delete pollRefs[bundleId];
const installing = { ...get().installing };
delete installing[bundleId];
set({ installing });
if (updated?.status === "error") {
set({
errors: { ...get().errors, [bundleId]: updated.error ?? "Installation failed" },
});
if (updated?.status === "queued") {
// Still waiting behind another install on the server; keep the pill.
if (!get().queued.includes(bundleId)) {
set({ queued: [...get().queued, bundleId] });
}
resolveCompletion(bundleId);
} else if (updated.progress) {
return;
}
if (updated?.status === "installing") {
// Now the active install: move queued -> installing and track progress.
const current = get().installing[bundleId];
const percent = Math.max(updated.progress.percent, current?.percent ?? 0);
const percent = Math.max(updated.progress?.percent ?? 0, current?.percent ?? 0);
set({
installing: {
...get().installing,
[bundleId]: { percent, stage: updated.progress.stage },
[bundleId]: { percent, stage: updated.progress?.stage ?? current?.stage ?? "" },
},
queued: get().queued.filter((id) => id !== bundleId),
});
return;
}
// Terminal: installed / error / not_installed.
clearInterval(pollRefs[bundleId]);
delete pollRefs[bundleId];
stopTracking(bundleId);
onInstallSettled(
bundleId,
updated?.status === "error" ? (updated.error ?? "Installation failed") : null,
);
} catch {}
}, 3000);
};
@@ -109,23 +152,20 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
if (data.phase === "complete") {
es.close();
delete esRefs[bundleId];
const installing = { ...get().installing };
delete installing[bundleId];
set({ installing });
stopTracking(bundleId);
refreshBundles();
resolveCompletion(bundleId);
onInstallSettled(bundleId, null);
return;
}
if (data.phase === "failed") {
es.close();
delete esRefs[bundleId];
const installing = { ...get().installing };
delete installing[bundleId];
set({ installing });
set({ errors: { ...get().errors, [bundleId]: data.error ?? "Installation failed" } });
resolveCompletion(bundleId);
stopTracking(bundleId);
onInstallSettled(bundleId, data.error ?? "Installation failed");
return;
}
// First progress frame means the server has started this install for
// real: move it out of the queued pill and into the installing map.
const current = get().installing[bundleId];
const percent = Math.max(data.percent, current?.percent ?? 0);
set({
@@ -133,6 +173,7 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
...get().installing,
[bundleId]: { percent, stage: data.stage },
},
queued: get().queued.filter((id) => id !== bundleId),
});
} catch {}
};
@@ -155,6 +196,12 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
startTimes: { ...get().startTimes, [bundle.id]: Date.now() },
});
startPolling(bundle.id);
} else if (bundle.status === "queued" && !get().queued.includes(bundle.id)) {
set({
queued: [...get().queued, bundle.id],
startTimes: { ...get().startTimes, [bundle.id]: Date.now() },
});
startPolling(bundle.id);
}
}
};
@@ -162,7 +209,7 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
const activeIds = Object.keys(get().installing);
const activeIds = [...Object.keys(get().installing), ...get().queued];
if (activeIds.length === 0) return;
for (const bundleId of activeIds) {
@@ -230,36 +277,9 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
},
installBundle: async (bundleId: string) => {
const activeIds = Object.keys(get().installing);
if (activeIds.length > 0 && !activeIds.includes(bundleId)) {
const alreadyQueued = get().queued.includes(bundleId);
if (!alreadyQueued) {
set({ queued: [...get().queued, bundleId] });
}
const errors = { ...get().errors };
delete errors[bundleId];
set({ errors });
await new Promise<void>((resolve) => {
const check = () => {
const current = get().installing;
if (Object.keys(current).length === 0 || Object.keys(current).includes(bundleId)) {
resolve();
} else {
setTimeout(check, 500);
}
};
check();
});
set({ queued: get().queued.filter((id) => id !== bundleId) });
const currentBundle = get().bundles.find((b) => b.id === bundleId);
if (currentBundle?.status === "installed") {
resolveCompletion(bundleId);
return;
}
}
// Always POST immediately -- the server owns the queue now, so a POST is
// durable even if this tab closes. Optimistically show "installing"; the
// response tells us whether it actually started or got queued.
const errors = { ...get().errors };
delete errors[bundleId];
set({
@@ -269,32 +289,38 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
});
try {
const result = await apiPost<{ jobId: string }>(
const result = await apiPost<{ jobId: string; queued?: boolean }>(
`/v1/admin/features/${bundleId}/install`,
{},
);
if (result.queued) {
// Server queued it behind an active install; show the pill instead of
// a progress bar until the first progress frame arrives.
const installing = { ...get().installing };
delete installing[bundleId];
set({
installing,
queued: get().queued.includes(bundleId) ? get().queued : [...get().queued, bundleId],
});
}
listenToProgress(bundleId, result.jobId);
} catch (err) {
const installing = { ...get().installing };
delete installing[bundleId];
stopTracking(bundleId);
const message = err instanceof Error ? err.message : "Failed to start installation";
const isAlreadyInstalled = /already installed/i.test(message);
if (isAlreadyInstalled) {
// 409 "already installed": clear the error and refresh status
// so the UI transitions to the installed state silently.
// 409 "already installed": clear the error and refresh status so the
// UI transitions to the installed state silently.
const errors = { ...get().errors };
delete errors[bundleId];
set({ installing, errors });
set({ errors });
await refreshBundles();
onInstallSettled(bundleId, null);
} else {
set({
installing,
errors: { ...get().errors, [bundleId]: message },
});
onInstallSettled(bundleId, message);
}
resolveCompletion(bundleId);
}
},
@@ -318,59 +344,28 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
},
installAll: async () => {
set({ installAllActive: true });
const pending = get().bundles.filter((b) => b.status !== "installed");
if (pending.length === 0) return;
// Immediately mark every not-yet-installed bundle as queued so the UI
// updates right away. Exclude bundles that are already installing.
const activeIds = new Set(Object.keys(get().installing));
const pending = get().bundles.filter((b) => b.status !== "installed" && !activeIds.has(b.id));
// Clear stale errors for these bundles
// Mark every pending bundle up front so the run never looks "drained"
// between POST dispatches (which would clear installAllActive early).
const errors = { ...get().errors };
for (const b of pending) delete errors[b.id];
set({ queued: pending.map((b) => b.id), errors });
// If an install is already in progress (user clicked an individual
// install before Install All), wait for it to finish first.
if (activeIds.size > 0) {
const activeId = [...activeIds][0];
await new Promise<void>((resolve) => {
completionRefs[activeId] = resolve;
});
await refreshBundles();
const installing = { ...get().installing };
const startTimes = { ...get().startTimes };
for (const b of pending) {
delete errors[b.id];
installing[b.id] = installing[b.id] ?? { percent: 5, stage: "Starting..." };
startTimes[b.id] = startTimes[b.id] ?? Date.now();
}
set({ installAllActive: true, errors, installing, startTimes });
// Process the queue sequentially. After each install, wait briefly
// so the backend lock file is fully released before the next attempt.
// If a bundle fails, re-enqueue it for one retry.
const retried = new Set<string>();
while (true) {
const q = get().queued;
if (q.length === 0) break;
const nextId = q[0];
set({ queued: q.slice(1) });
const current = get().bundles.find((b) => b.id === nextId);
if (current?.status === "installed") continue;
await new Promise<void>((resolve) => {
completionRefs[nextId] = resolve;
get().installBundle(nextId);
});
await refreshBundles();
const after = get().bundles.find((b) => b.id === nextId);
if (after?.status !== "installed" && !retried.has(nextId)) {
retried.add(nextId);
const errors = { ...get().errors };
delete errors[nextId];
set({ queued: [...get().queued, nextId], errors });
}
// Brief pause to let the backend fully release the install lock
await new Promise((r) => setTimeout(r, 2000));
// Fire one POST per pending bundle. The server serializes them behind its
// queue; installBundle() reconciles installing vs queued from each
// response and installAllActive clears once installing[] and queued[]
// both drain (see maybeFinishInstallAll).
for (const b of pending) {
get().installBundle(b.id);
}
set({ queued: [], installAllActive: false });
},
clearError: (bundleId: string) => {
+127 -17
View File
@@ -12,6 +12,7 @@ Progress is reported via JSON lines on stderr (parsed by the Node bridge).
Final result is a JSON object on stdout.
"""
import errno
import glob
import hashlib
import json
@@ -45,7 +46,19 @@ def fail(message: str) -> None:
# -- Architecture detection --
def detect_arch() -> str:
"""Return 'amd64-gpu' or 'arm64-cpu' based on host + GPU."""
"""Return the bundle archive key for this host.
Only two archive variants are currently published to the bundle repo:
'amd64-gpu' and 'arm64-cpu' (see deepsafe/feature-bundles). There is no
CPU-only amd64 variant yet, so amd64 hosts always resolve to 'amd64-gpu'
even when no GPU is present -- this downloads working CUDA-capable
packages, just larger than a CPU-only host strictly needs. Do not change
this to branch on GPU presence without first publishing an 'amd64-cpu'
archive for every bundle; requesting a key that doesn't exist in the
manifest fails the install outright (see the archives.get(arch) lookup
below), which would be worse than the current oversized-but-working
download.
"""
machine = platform.machine().lower()
if machine in ("aarch64", "arm64"):
return "arm64-cpu"
@@ -54,9 +67,21 @@ def detect_arch() -> str:
# -- Disk space --
def _existing_ancestor(path: str) -> str:
"""Nearest existing ancestor of path (so disk_usage never raises on a
not-yet-created dir like the venv)."""
p = os.path.abspath(path)
while p and not os.path.exists(p):
parent = os.path.dirname(p)
if parent == p:
break
p = parent
return p or "/"
def check_disk_space(path: str, needed_bytes: int) -> None:
"""Fail if insufficient disk space."""
usage = shutil.disk_usage(path)
"""Fail if insufficient disk space on the filesystem holding path."""
usage = shutil.disk_usage(_existing_ancestor(path))
if usage.free < needed_bytes:
free_gb = usage.free / (1024 ** 3)
need_gb = needed_bytes / (1024 ** 3)
@@ -67,6 +92,37 @@ def check_disk_space(path: str, needed_bytes: int) -> None:
)
def estimate_extracted(compressed: int, extracted: int) -> int:
"""Extracted-size estimate for the disk preflight. When the manifest omits
extractedSize (0), the budget would otherwise collapse to just the
compressed size and under-reserve for the extracted payload; fall back to a
conservative 3x of compressed (measured extracted/compressed ratios reach
~3x). This is only the early sanity bail -- the accurate guard is the
real-on-disk re-check just before the destructive venv write."""
return extracted if extracted > 0 else compressed * 3
def dir_size(path: str) -> int:
"""Total size in bytes of all files under path (best-effort)."""
total = 0
for root, _dirs, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
return total
def same_filesystem(a: str, b: str) -> bool:
"""True if paths a and b live on the same filesystem (so a rename between
them is a cheap metadata op rather than a full copy)."""
try:
return os.stat(_existing_ancestor(a)).st_dev == os.stat(_existing_ancestor(b)).st_dev
except OSError:
return False
# -- Venv site-packages discovery --
def get_site_packages_dir(venv_path: str) -> str:
@@ -214,10 +270,39 @@ def safe_extract(tar_path: str, staging_dir: str) -> None:
# -- File move --
def move_tree(src: str, dst: str) -> None:
"""Recursively merge src into dst, overwriting existing files."""
if os.path.isdir(src):
shutil.copytree(src, dst, dirs_exist_ok=True)
shutil.rmtree(src)
"""Merge src into dst, overwriting existing files. Renames entries where
possible so that on the same filesystem no copy (and thus no transient
doubling of the payload on disk) occurs; falls back to a copy only across
filesystems. The old copytree+rmtree approach duplicated the whole tree on
disk during the move, which could exhaust the host on a tight-disk node."""
if not os.path.isdir(src):
return
os.makedirs(dst, exist_ok=True)
for name in os.listdir(src):
s = os.path.join(src, name)
d = os.path.join(dst, name)
if os.path.isdir(s) and os.path.isdir(d):
# Both dirs exist: merge recursively rather than replace.
move_tree(s, d)
continue
if os.path.exists(d):
if os.path.isdir(d):
shutil.rmtree(d)
else:
os.remove(d)
try:
os.rename(s, d)
except OSError as e:
if getattr(e, "errno", None) == errno.EXDEV:
# Cross-filesystem: rename isn't allowed, fall back to a copy.
if os.path.isdir(s):
shutil.copytree(s, d)
else:
shutil.copy2(s, d)
else:
raise
# Remove whatever remains of src (emptied by renames, or copied originals).
shutil.rmtree(src, ignore_errors=True)
# -- Fixups (NCCL wheel) --
@@ -338,8 +423,12 @@ def main() -> None:
bundle_repo = manifest.get("bundleRepo", "deepsafe/feature-bundles")
url = f"https://huggingface.co/{bundle_repo}/resolve/main/{archive_file}"
# Disk space check
needed = compressed_size + extracted_size + 500 * 1024 * 1024 # 500 MB buffer
# Disk space check (early sanity bail before a multi-GB download).
# estimate_extracted covers the extractedSize:0 case so the budget can't
# collapse to just the compressed size; the accurate guard is the
# real-on-disk re-check just before the destructive venv write below.
needed = compressed_size + estimate_extracted(compressed_size, extracted_size)
needed += 500 * 1024 * 1024 # 500 MB buffer
if needed > 0:
check_disk_space(ai_dir, needed)
@@ -407,20 +496,41 @@ def main() -> None:
version = bundle_meta.get("version", manifest.get("imageVersion", "unknown"))
model_ids = bundle_meta.get("models", [])
# -- Disk re-check before the first destructive venv write --
# The upfront check ran before the download and used an estimate; now the
# payload is really on disk, so measure it and verify there's room to place
# it before we start writing into the venv. On the same filesystem the move
# is a rename (no extra space needed, just a safety floor); across
# filesystems it is a copy that transiently needs the payload's size again.
# Running here (after the local/remote branches merge) also covers the
# offline-import path, which skipped the upfront check entirely.
staging_real = dir_size(staging_dir)
if same_filesystem(staging_dir, venv_path):
recheck_needed = 1024 ** 3 # 1 GB floor for fixups / installed.json / slack
else:
recheck_needed = staging_real + 1024 ** 3
check_disk_space(ai_dir, recheck_needed)
# -- Move site-packages --
emit_progress(92, "Installing packages...")
site_packages_dir = get_site_packages_dir(venv_path)
staging_sp = os.path.join(staging_dir, "site-packages")
if os.path.isdir(staging_sp) and site_packages_dir:
move_tree(staging_sp, site_packages_dir)
try:
if os.path.isdir(staging_sp) and site_packages_dir:
move_tree(staging_sp, site_packages_dir)
# -- Move models --
emit_progress(95, "Installing models...")
staging_models = os.path.join(staging_dir, "models")
if os.path.isdir(staging_models):
os.makedirs(models_dir, exist_ok=True)
move_tree(staging_models, models_dir)
# -- Move models --
emit_progress(95, "Installing models...")
staging_models = os.path.join(staging_dir, "models")
if os.path.isdir(staging_models):
os.makedirs(models_dir, exist_ok=True)
move_tree(staging_models, models_dir)
except OSError as e:
shutil.rmtree(staging_dir, ignore_errors=True)
if getattr(e, "errno", None) == errno.ENOSPC:
fail("Ran out of disk space while installing the bundle. Free up space and retry.")
fail(f"Failed to install bundle files: {e}")
# -- Apply fixups --
emit_progress(97, "Finalizing...")
+14 -1
View File
@@ -130,7 +130,20 @@ export async function seamCarve(
const currentMp = (currentW * currentH) / 1_000_000;
const timeoutMs = Math.ceil(Math.max(120_000, currentMp * 10_000));
await execFileAsync(cairePath, args, { timeout: timeoutMs });
try {
await execFileAsync(cairePath, args, { timeout: timeoutMs });
} catch (err) {
// execFile sets killed=true when it terminates the child on timeout.
// Replace the raw caire terminal output (ANSI/spinner control chars) with
// an actionable message; keep the original as `cause` for server logs.
if ((err as { killed?: boolean }).killed) {
throw new Error(
"Content-aware resize timed out on this image. Try a smaller image or a larger target size.",
{ cause: err },
);
}
throw err;
}
const buffer = await readFile(outputPath);
const outMeta = await sharp(buffer).metadata();
+11 -4
View File
@@ -6,7 +6,7 @@ export interface FeatureBundleInfo {
enablesTools: string[];
}
export type FeatureStatus = "not_installed" | "installing" | "installed" | "error";
export type FeatureStatus = "not_installed" | "queued" | "installing" | "installed" | "error";
export interface FeatureBundleState {
id: string;
@@ -15,6 +15,13 @@ export interface FeatureBundleState {
status: FeatureStatus;
installedVersion: string | null;
estimatedSize: string;
// Real download / on-disk sizes for THIS host's architecture, read from the
// bundle manifest. amd64 hosts pull the CUDA-inclusive archive whether or not
// a GPU is present, so these can be much larger than the coarse estimatedSize
// label suggests. Optional/nullable: absent in native (non-Docker) mode and
// when the manifest lacks the value (extractedSize is 0 for some archives).
downloadBytes?: number | null;
installedBytes?: number | null;
enablesTools: string[];
progress: { percent: number; stage: string } | null;
error: string | null;
@@ -52,21 +59,21 @@ export const FEATURE_BUNDLES: Record<string, FeatureBundleInfo> = {
id: "upscale-enhance",
name: "Upscale & Enhance",
description: "AI upscaling, face enhancement, and noise removal",
estimatedSize: "4-5 GB",
estimatedSize: "5-6 GB",
enablesTools: ["upscale", "enhance-faces", "noise-removal"],
},
"photo-restoration": {
id: "photo-restoration",
name: "Photo Restoration",
description: "Restore old or damaged photos",
estimatedSize: "800 MB - 1 GB",
estimatedSize: "4-5 GB",
enablesTools: ["restore-photo"],
},
ocr: {
id: "ocr",
name: "OCR",
description: "Extract text from images",
estimatedSize: "3-4 GB",
estimatedSize: "5-6 GB",
enablesTools: ["ocr", "ocr-pdf"],
},
transcription: {
+1 -1
View File
@@ -3704,7 +3704,7 @@ export const ar: TranslationKeys = {
urlFetchFailed: "تعذر جلب الصورة من الرابط",
ariaLabel: "منطقة إسقاط الملفات",
dropPrompt: "اسحب صورك هنا",
browseOrPaste: "اضغط في أي مكان للتصفح، أو الصق من الحافظة",
browseOrPaste: "استخدم زر التحميل، أو الصق من الحافظة",
uploadButton: "رفع",
defaultFormats: "PNG، JPG، WebP، HEIC، RAW، PSD، وأكثر من 65 صيغة",
orSeparator: "أو",
+2 -1
View File
@@ -3747,7 +3747,8 @@ export const de: TranslationKeys = {
urlFetchFailed: "Bild konnte nicht von URL abgerufen werden",
ariaLabel: "Datei-Ablagezone",
dropPrompt: "Bilder hierher ziehen",
browseOrPaste: "Klicken Sie zum Durchsuchen oder fügen Sie aus der Zwischenablage ein",
browseOrPaste:
"Verwenden Sie die Upload-Schaltfläche oder fügen Sie aus der Zwischenablage ein",
uploadButton: "Hochladen",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD und 65+ Formate",
orSeparator: "oder",
+1 -1
View File
@@ -3674,7 +3674,7 @@ export const en = {
urlFetchFailed: "Could not fetch file from URL",
ariaLabel: "File drop zone",
dropPrompt: "Drop your files here",
browseOrPaste: "click anywhere to browse, or paste from clipboard",
browseOrPaste: "use the upload button, or paste from clipboard",
uploadButton: "Upload",
defaultFormats: "Images, Videos, Audio, PDFs, Documents, and 150+ formats",
orSeparator: "or",
+1 -1
View File
@@ -3726,7 +3726,7 @@ export const es: TranslationKeys = {
urlFetchFailed: "No se pudo obtener la imagen desde la URL",
ariaLabel: "Zona de carga de archivos",
dropPrompt: "Arrastra tus imágenes aquí",
browseOrPaste: "haz clic en cualquier lugar para explorar, o pega desde el portapapeles",
browseOrPaste: "usa el botón de subida o pega desde el portapapeles",
uploadButton: "Subir",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD y más de 65 formatos",
orSeparator: "o",
+1 -1
View File
@@ -3751,7 +3751,7 @@ export const fr: TranslationKeys = {
urlFetchFailed: "Impossible de récupérer l'image depuis l'URL",
ariaLabel: "Zone de dépôt de fichiers",
dropPrompt: "Déposez vos images ici",
browseOrPaste: "cliquez n'importe où pour parcourir, ou collez depuis le presse-papiers",
browseOrPaste: "utilisez le bouton d'import, ou collez depuis le presse-papiers",
uploadButton: "Importer",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD et plus de 65 formats",
orSeparator: "ou",
+1 -1
View File
@@ -3534,7 +3534,7 @@ export const hi: TranslationKeys = {
urlFetchFailed: "URL से इमेज नहीं ला सके",
ariaLabel: "फाइल ड्रॉप ज़ोन",
dropPrompt: "अपनी इमेज यहां खींचें",
browseOrPaste: "ब्राउज़ करने के लिए कहीं भी क्लिक करें, या क्लिपबोर्ड से पेस्ट करें",
browseOrPaste: "अपलोड बटन का उपयोग करें, या क्लिपबोर्ड से पेस्ट करें",
uploadButton: "अपलोड",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD, और 65+ फॉर्मेट",
orSeparator: "या",
+1 -1
View File
@@ -3727,7 +3727,7 @@ export const id: TranslationKeys = {
urlFetchFailed: "Tidak dapat mengambil gambar dari URL",
ariaLabel: "Area seret file",
dropPrompt: "Seret gambar Anda ke sini",
browseOrPaste: "klik untuk menjelajah, atau tempel dari clipboard",
browseOrPaste: "gunakan tombol unggah, atau tempel dari clipboard",
uploadButton: "Unggah",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD, dan 65+ format",
orSeparator: "atau",
+30 -3
View File
@@ -1,3 +1,4 @@
import type { TranslationKeys } from "./en.js";
import { en } from "./en.js";
export type { TranslationKeys } from "./en.js";
@@ -34,11 +35,37 @@ export const SUPPORTED_LOCALES: SupportedLocale[] = [
{ code: "th", name: "Thai", nativeName: "ไทย", dir: "ltr" },
];
export async function loadTranslations(locale: string): Promise<import("./en.js").TranslationKeys> {
type TranslationLoader = () => Promise<TranslationKeys>;
const TRANSLATION_LOADERS: Record<string, TranslationLoader> = {
ar: async () => (await import("./ar.js")).ar,
de: async () => (await import("./de.js")).de,
es: async () => (await import("./es.js")).es,
fr: async () => (await import("./fr.js")).fr,
hi: async () => (await import("./hi.js")).hi,
id: async () => (await import("./id.js")).id,
it: async () => (await import("./it.js")).it,
ja: async () => (await import("./ja.js")).ja,
ko: async () => (await import("./ko.js")).ko,
nl: async () => (await import("./nl.js")).nl,
pl: async () => (await import("./pl.js")).pl,
"pt-BR": async () => (await import("./pt-BR.js")).ptBR,
ru: async () => (await import("./ru.js")).ru,
sv: async () => (await import("./sv.js")).sv,
th: async () => (await import("./th.js")).th,
tr: async () => (await import("./tr.js")).tr,
uk: async () => (await import("./uk.js")).uk,
vi: async () => (await import("./vi.js")).vi,
"zh-CN": async () => (await import("./zh-CN.js")).zhCN,
"zh-TW": async () => (await import("./zh-TW.js")).zhTW,
};
export async function loadTranslations(locale: string): Promise<TranslationKeys> {
if (locale === "en") return en;
const loadTranslation = TRANSLATION_LOADERS[locale];
if (!loadTranslation) return en;
try {
const mod = await import(`./${locale}.js`);
return mod[locale] ?? mod.default ?? en;
return await loadTranslation();
} catch {
return en;
}
+1 -1
View File
@@ -3740,7 +3740,7 @@ export const it: TranslationKeys = {
urlFetchFailed: "Impossibile recuperare l'immagine dall'URL",
ariaLabel: "Area di rilascio file",
dropPrompt: "Trascina le tue immagini qui",
browseOrPaste: "fai clic ovunque per sfogliare, oppure incolla dagli appunti",
browseOrPaste: "usa il pulsante di caricamento oppure incolla dagli appunti",
uploadButton: "Carica",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD e oltre 65 formati",
orSeparator: "o",
+1 -1
View File
@@ -3677,7 +3677,7 @@ export const ja: TranslationKeys = {
urlFetchFailed: "URLから画像を取得できませんでした",
ariaLabel: "ファイルドロップゾーン",
dropPrompt: "ここに画像をドロップ",
browseOrPaste: "クリックでファイルを参照、またはクリップボードから貼り付け",
browseOrPaste: "アップロードボタンを使用するか、クリップボードから貼り付け",
uploadButton: "アップロード",
defaultFormats: "PNG、JPG、WebP、HEIC、RAW、PSD、65以上のフォーマット",
orSeparator: "または",
+1 -1
View File
@@ -3659,7 +3659,7 @@ export const ko: TranslationKeys = {
urlFetchFailed: "URL에서 이미지를 가져올 수 없습니다",
ariaLabel: "파일 드롭 영역",
dropPrompt: "여기에 이미지를 드롭하세요",
browseOrPaste: "아무 곳이나 클릭하여 파일 탐색, 또는 클립보드에서 붙여넣기",
browseOrPaste: "업로드 버튼을 사용하거나 클립보드에서 붙여넣기",
uploadButton: "업로드",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD 외 65가지 이상의 포맷",
orSeparator: "또는",
+1 -1
View File
@@ -3736,7 +3736,7 @@ export const nl: TranslationKeys = {
urlFetchFailed: "Afbeelding kon niet worden opgehaald van URL",
ariaLabel: "Bestand-dropzone",
dropPrompt: "Sleep je afbeeldingen hierheen",
browseOrPaste: "klik om te bladeren, of plak vanuit het klembord",
browseOrPaste: "gebruik de uploadknop of plak vanuit het klembord",
uploadButton: "Uploaden",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD en 65+ formaten",
orSeparator: "of",
+1 -1
View File
@@ -3742,7 +3742,7 @@ export const pl: TranslationKeys = {
urlFetchFailed: "Nie udało się pobrać obrazu z URL",
ariaLabel: "Strefa upuszczania plików",
dropPrompt: "Przeciągnij tutaj swoje obrazy",
browseOrPaste: "kliknij gdziekolwiek, aby przeglądać, lub wklej ze schowka",
browseOrPaste: "użyj przycisku przesyłania lub wklej ze schowka",
uploadButton: "Prześlij",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD i ponad 65 formatów",
orSeparator: "lub",
+1 -1
View File
@@ -3734,7 +3734,7 @@ export const ptBR: TranslationKeys = {
urlFetchFailed: "Não foi possível obter a imagem da URL",
ariaLabel: "Área de soltar arquivos",
dropPrompt: "Arraste suas imagens aqui",
browseOrPaste: "clique em qualquer lugar para navegar, ou cole da área de transferência",
browseOrPaste: "use o botão de upload ou cole da área de transferência",
uploadButton: "Enviar",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD e mais de 65 formatos",
orSeparator: "ou",
+1 -1
View File
@@ -3730,7 +3730,7 @@ export const ru: TranslationKeys = {
urlFetchFailed: "Не удалось загрузить изображение по URL",
ariaLabel: "Зона перетаскивания файлов",
dropPrompt: "Перетащите изображения сюда",
browseOrPaste: "нажмите в любом месте для выбора или вставьте из буфера обмена",
browseOrPaste: "используйте кнопку загрузки или вставьте из буфера обмена",
uploadButton: "Загрузить",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD и 65+ форматов",
orSeparator: "или",
+1 -1
View File
@@ -3724,7 +3724,7 @@ export const sv: TranslationKeys = {
urlFetchFailed: "Kunde inte hämta bild från URL",
ariaLabel: "Filsläppzon",
dropPrompt: "Släpp dina bilder här",
browseOrPaste: "klicka för att bläddra, eller klistra in från urklipp",
browseOrPaste: "använd uppladdningsknappen eller klistra in från urklipp",
uploadButton: "Ladda upp",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD och 65+ format",
orSeparator: "eller",
+1 -1
View File
@@ -3684,7 +3684,7 @@ export const th: TranslationKeys = {
urlFetchFailed: "ไม่สามารถดึงภาพจาก URL",
ariaLabel: "พื้นที่วางไฟล์",
dropPrompt: "ลากภาพมาวางที่นี่",
browseOrPaste: "คลิกเพื่อเรียกดูหรือวางจากคลิปบอร์ด",
browseOrPaste: "ใช้ปุ่มอัปโหลดหรือวางจากคลิปบอร์ด",
uploadButton: "อัปโหลด",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD และรูปแบบอื่นกว่า 65 รูปแบบ",
orSeparator: "หรือ",
+1 -1
View File
@@ -3734,7 +3734,7 @@ export const tr: TranslationKeys = {
urlFetchFailed: "URL'den görüntü alınamadı",
ariaLabel: "Dosya bırakma alanı",
dropPrompt: "Görüntülerinizi buraya bırakın",
browseOrPaste: "göz atmak için herhangi bir yere tıklayın veya panodan yapıştırın",
browseOrPaste: "yükleme düğmesini kullanın veya panodan yapıştırın",
uploadButton: "Yükle",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD ve 65+ biçim",
orSeparator: "veya",
+1 -1
View File
@@ -3731,7 +3731,7 @@ export const uk: TranslationKeys = {
urlFetchFailed: "Не вдалося завантажити зображення за URL",
ariaLabel: "Зона перетягування файлів",
dropPrompt: "Перетягніть зображення сюди",
browseOrPaste: "натисніть будь-де для вибору або вставте з буфера обміну",
browseOrPaste: "скористайтеся кнопкою завантаження або вставте з буфера обміну",
uploadButton: "Завантажити",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD та 65+ форматів",
orSeparator: "або",
+1 -1
View File
@@ -3724,7 +3724,7 @@ export const vi: TranslationKeys = {
urlFetchFailed: "Không thể tải ảnh từ URL",
ariaLabel: "Vùng thả tệp",
dropPrompt: "Kéo thả hình ảnh vào đây",
browseOrPaste: "nhấp để duyệt hoặc dán từ bộ nhớ tạm",
browseOrPaste: "dùng nút tải lên hoặc dán từ bộ nhớ tạm",
uploadButton: "Tải lên",
defaultFormats: "PNG, JPG, WebP, HEIC, RAW, PSD và hơn 65 định dạng",
orSeparator: "hoặc",
+1 -1
View File
@@ -3469,7 +3469,7 @@ export const zhCN: TranslationKeys = {
urlFetchFailed: "无法从 URL 获取图片",
ariaLabel: "文件拖放区域",
dropPrompt: "将图片拖放到此处",
browseOrPaste: "点击任意位置浏览,或从剪贴板粘贴",
browseOrPaste: "使用上传按钮,或从剪贴板粘贴",
uploadButton: "上传",
defaultFormats: "PNG、JPG、WebP、HEIC、RAW、PSD 及 65+ 种格式",
orSeparator: "或",
+1 -1
View File
@@ -3468,7 +3468,7 @@ export const zhTW: TranslationKeys = {
urlFetchFailed: "無法從URL取得影像",
ariaLabel: "檔案拖放區域",
dropPrompt: "將影像拖放到此處",
browseOrPaste: "點擊任意位置瀏覽檔案,或從剪貼簿貼上",
browseOrPaste: "使用上傳按鈕,或從剪貼簿貼上",
uploadButton: "上傳",
defaultFormats: "PNG、JPG、WebP、HEIC、RAW、PSD及65+種格式",
orSeparator: "或",
+28 -10
View File
@@ -12,7 +12,7 @@ const pngBuffer = Buffer.from(
"base64",
);
const VALID_STATUSES = ["not_installed", "installed", "installing", "error"];
const VALID_STATUSES = ["not_installed", "queued", "installed", "installing", "error"];
let _token: string | undefined;
@@ -296,26 +296,44 @@ test.describe("Install lifecycle - face-detection", () => {
expect(["installing", "installed"]).toContain(bundle.status);
});
test("second install of same bundle returns 409 during install", async ({ request }) => {
// New contract: the server owns the install queue, so a concurrent install is
// no longer rejected with 409. A repeat POST of the *same* installing bundle
// is deduped (202, not newly queued, same job); a *different* bundle is
// queued (202 { queued: true }) and reported as "queued" via GET /features.
test("second install of same bundle is deduped (202, not a new job)", async ({ request }) => {
const status = await getBundleStatus(request, "face-detection");
if (status === "installing") {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/face-detection/install`, {
headers,
});
expect(res.status()).toBe(409);
expect(res.status()).toBe(202);
const body = await res.json();
expect(body.queued).toBe(false);
expect(typeof body.jobId).toBe("string");
}
// If already installed (fast download), this test is a no-op
});
test("install of different bundle returns 409 during install", async ({ request }) => {
test("install of different bundle while one is active is queued (202)", async ({ request }) => {
test.setTimeout(900_000);
const status = await getBundleStatus(request, "face-detection");
if (status === "installing") {
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/ocr/install`, { headers });
expect(res.status()).toBe(409);
}
// If already installed (fast download), this test is a no-op
if (status !== "installing") return; // fast install already finished; nothing to assert
const headers = await authHeaders(request);
const res = await request.post(`${API}/api/v1/admin/features/ocr/install`, { headers });
expect(res.status()).toBe(202);
expect((await res.json()).queued).toBe(true);
const ocr = await getBundle(request, "ocr");
expect(["queued", "installing"]).toContain(ocr.status);
// ocr auto-starts once face-detection finishes. Drain and uninstall it so
// the rest of the serial suite runs against a clean lock/state.
await waitForInstallComplete(request, "face-detection");
await waitForInstallComplete(request, "ocr", 900_000);
await request.post(`${API}/api/v1/admin/features/ocr/uninstall`, { headers });
expect(await getBundleStatus(request, "ocr")).toBe("not_installed");
});
test("after install completes, status is installed with version", async ({ request }) => {
+3 -1
View File
@@ -60,7 +60,9 @@ test.describe("Feature API", () => {
expect(bundle.estimatedSize).toBeTruthy();
expect(bundle.enablesTools).toBeInstanceOf(Array);
expect(bundle.enablesTools.length).toBeGreaterThan(0);
expect(["not_installed", "installed", "installing", "error"]).toContain(bundle.status);
expect(["not_installed", "queued", "installed", "installing", "error"]).toContain(
bundle.status,
);
}
});
+8 -2
View File
@@ -292,7 +292,10 @@ test.describe("GUI Watermark & Overlay Tools", () => {
test.describe("Compose", () => {
async function uploadBaseImage(page: Page) {
const fileChooserPromise = page.waitForEvent("filechooser");
await page.locator("section[aria-label='File drop zone']").click();
await page
.locator("section[aria-label='File drop zone']")
.getByRole("button", { name: /upload from computer/i })
.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(getTestImagePath());
await page.waitForTimeout(500);
@@ -662,7 +665,10 @@ test.describe("GUI Watermark & Overlay Tools", () => {
// Use compose-specific upload helpers
const fileChooserPromise = page.waitForEvent("filechooser");
await page.locator("section[aria-label='File drop zone']").click();
await page
.locator("section[aria-label='File drop zone']")
.getByRole("button", { name: /upload from computer/i })
.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(getTestImagePath());
await page.waitForTimeout(500);
+5
View File
@@ -0,0 +1,5 @@
[
{ "label": "Ada", "value": 36 },
{ "label": "Grace", "value": 45 },
{ "label": "Alan", "value": 41 }
]
@@ -106,6 +106,54 @@ describe("Login failures", () => {
expect(res.statusCode).toBe(401);
});
it("unknown username and wrong password take comparable time (no enumeration timing oracle)", async () => {
// Both cases must return the identical 401 body, but a naive implementation
// short-circuits on "user not found" before ever running the password
// hash (scrypt), while "wrong password for a real user" always pays the
// scrypt cost. That gap lets an attacker enumerate valid usernames purely
// from response timing even though the status code and body are identical.
// See getDummyHash() in apps/api/src/plugins/auth.ts -- it equalizes cost
// by running verifyPassword against a dummy hash on the unknown-user path.
const SAMPLES = 10;
const median = (values: number[]) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
};
const unknownUserTimes: number[] = [];
for (let i = 0; i < SAMPLES; i++) {
const start = performance.now();
await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: `nonexistent_${uid()}_${i}`, password: "Whatever1" },
});
unknownUserTimes.push(performance.now() - start);
}
const wrongPasswordTimes: number[] = [];
for (let i = 0; i < SAMPLES; i++) {
const start = performance.now();
await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "admin", password: `WrongPass1_${i}` },
});
wrongPasswordTimes.push(performance.now() - start);
}
const unknownMedian = median(unknownUserTimes);
const wrongPasswordMedian = median(wrongPasswordTimes);
const ratio =
Math.max(unknownMedian, wrongPasswordMedian) /
Math.max(1, Math.min(unknownMedian, wrongPasswordMedian));
// A real (unfixed) timing oracle shows up as 5-10x+ here (unknown-user
// returns near-instantly; wrong-password waits on scrypt). Bound at 3x to
// absorb normal event-loop/GC jitter while still catching a regression.
expect(ratio).toBeLessThan(3);
}, 30_000);
it("failed logins generate LOGIN_FAILED audit events", async () => {
const marker = uid();
// Trigger a failed login with a unique username
@@ -0,0 +1,292 @@
/**
* Integration tests for the server-side feature-install queue at the HTTP route
* level.
*
* The installer child process (spawn) and the venv lock (@snapotter/ai) are
* mocked so no real Python runs: spawn returns a controllable fake child we can
* drive with emit("close"). This lets us assert the route contract
* deterministically -- a second concurrent install is queued (202 { queued:
* true }) instead of rejected, the next queued bundle auto-starts when the
* running one finishes, and a bundle queued while an import holds the lock
* starts once the import route releases it.
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// ── Hoisted mocks (spawn + venv lock) ────────────────────────────
const hoisted = vi.hoisted(() => {
// Minimal event emitter (no node:events import; vi.hoisted runs pre-import).
function makeEmitter() {
const listeners: Record<string, Array<(...a: unknown[]) => void>> = {};
return {
on(event: string, cb: (...a: unknown[]) => void) {
listeners[event] ??= [];
listeners[event].push(cb);
return this;
},
emit(event: string, ...args: unknown[]) {
for (const cb of listeners[event] ?? []) cb(...args);
},
};
}
interface FakeChild {
bundleId: string;
stdout: ReturnType<typeof makeEmitter>;
stderr: ReturnType<typeof makeEmitter>;
on: (event: string, cb: (...a: unknown[]) => void) => unknown;
emit: (event: string, ...args: unknown[]) => void;
}
const spawnCalls: FakeChild[] = [];
const spawnMock = vi.fn((_cmd: string, args: string[]) => {
const base = makeEmitter() as unknown as FakeChild;
base.bundleId = args[1];
base.stdout = makeEmitter();
base.stderr = makeEmitter();
spawnCalls.push(base);
return base;
});
const acquireVenvLockMock = vi.fn(async () => () => {});
const shutdownDispatcherMock = vi.fn();
return { spawnCalls, spawnMock, acquireVenvLockMock, shutdownDispatcherMock };
});
vi.mock("node:child_process", async (importOriginal) => {
const actual = (await importOriginal()) as typeof import("node:child_process");
return { ...actual, spawn: hoisted.spawnMock };
});
vi.mock("@snapotter/ai", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
acquireVenvLock: hoisted.acquireVenvLockMock,
shutdownDispatcher: hoisted.shutdownDispatcherMock,
};
});
// ── Temp DATA_DIR before importing feature-status ────────────────
const testRoot = join(tmpdir(), `snapotter-install-queue-${randomUUID()}`);
const aiDir = join(testRoot, "ai");
const modelsDir = join(aiDir, "models");
const installedPath = join(aiDir, "installed.json");
process.env.DATA_DIR = testRoot;
// Point at the real manifest so isDockerEnvironment() is true (GET /features
// then goes through getFeatureStates instead of the native "all installed"
// short-circuit) and import bundleId validation has a manifest to read.
process.env.FEATURE_MANIFEST_PATH = join(process.cwd(), "docker/feature-manifest.json");
mkdirSync(modelsDir, { recursive: true });
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
// ── Dynamic imports (after env + mocks) ──────────────────────────
const { acquireInstallLock, releaseInstallLock, invalidateCache } = await import(
"../../../apps/api/src/lib/feature-status.js"
);
const queue = await import("../../../apps/api/src/lib/feature-install-queue.js");
const { createMultipartPayload, loginAsAdmin } = await import("../test-server.js");
// ── Helpers ──────────────────────────────────────────────────────
async function waitFor(cond: () => boolean, timeoutMs = 3000): Promise<void> {
const start = Date.now();
while (!cond()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor: condition not met in time");
await new Promise((r) => setTimeout(r, 5));
}
}
async function tick(): Promise<void> {
await new Promise((r) => setTimeout(r, 40));
}
describe("POST /api/v1/admin/features/:bundleId/install queue", () => {
let app: Awaited<ReturnType<typeof import("fastify")>>["default"] extends (
...args: infer _A
) => infer R
? R
: never;
let token: string;
beforeAll(async () => {
const Fastify = (await import("fastify")).default;
const multipartPlugin = (await import("@fastify/multipart")).default;
const cookie = (await import("@fastify/cookie")).default;
const cors = (await import("@fastify/cors")).default;
app = Fastify({ logger: false, bodyLimit: 100 * 1024 * 1024 });
await app.register(cors, { origin: true });
await app.register(multipartPlugin, { limits: { fileSize: 100 * 1024 * 1024 } });
await app.register(cookie, { secret: "test-cookie-secret", hook: "onRequest" });
const { authMiddleware, authRoutes, ensureBuiltinRoles, ensureDefaultAdmin } = await import(
"../../../apps/api/src/plugins/auth.js"
);
await authMiddleware(app);
await authRoutes(app);
await ensureBuiltinRoles();
await ensureDefaultAdmin();
const { db, schema } = await import("../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "admin"));
const { registerFeatureRoutes } = await import("../../../apps/api/src/routes/features.js");
await registerFeatureRoutes(app);
token = await loginAsAdmin(app);
});
afterAll(async () => {
if (app) await app.close();
});
beforeEach(() => {
queue.resetQueueState();
try {
releaseInstallLock();
} catch {
// no lock held
}
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
invalidateCache();
hoisted.spawnCalls.length = 0;
hoisted.spawnMock.mockClear();
});
const auth = () => ({ authorization: `Bearer ${token}` });
async function postInstall(bundleId: string) {
return app.inject({
method: "POST",
url: `/api/v1/admin/features/${bundleId}/install`,
headers: auth(),
});
}
async function getFeatures() {
const res = await app.inject({ method: "GET", url: "/api/v1/features", headers: auth() });
return JSON.parse(res.body).bundles as Array<{ id: string; status: string }>;
}
it("first install starts immediately (queued: false) and spawns once", async () => {
const res = await postInstall("ocr");
expect(res.statusCode).toBe(202);
const body = JSON.parse(res.body);
expect(body.queued).toBe(false);
expect(typeof body.jobId).toBe("string");
await waitFor(() => hoisted.spawnCalls.length === 1);
expect(hoisted.spawnCalls[0].bundleId).toBe("ocr");
});
it("a concurrent install is queued (202 queued: true) and does NOT spawn a second process", async () => {
const r1 = await postInstall("ocr");
expect(JSON.parse(r1.body).queued).toBe(false);
await waitFor(() => hoisted.spawnCalls.length === 1);
const r2 = await postInstall("face-detection");
expect(r2.statusCode).toBe(202);
expect(JSON.parse(r2.body).queued).toBe(true);
// Give any (incorrect) spawn a chance to fire; it must not.
await tick();
expect(hoisted.spawnCalls.length).toBe(1);
const bundles = await getFeatures();
expect(bundles.find((b) => b.id === "ocr")?.status).toBe("installing");
expect(bundles.find((b) => b.id === "face-detection")?.status).toBe("queued");
});
it("dedups a duplicate install POST of the active bundle (no second entry, same job)", async () => {
const r1 = await postInstall("ocr");
const jobId1 = JSON.parse(r1.body).jobId;
await waitFor(() => hoisted.spawnCalls.length === 1);
// POST the SAME bundle again while it is active.
const r2 = await postInstall("ocr");
expect(r2.statusCode).toBe(202);
const body2 = JSON.parse(r2.body);
expect(body2.queued).toBe(false);
// Returns the in-flight job id, not a new one.
expect(body2.jobId).toBe(jobId1);
await tick();
expect(hoisted.spawnCalls.length).toBe(1);
});
it("auto-starts the next queued bundle when the running install finishes", async () => {
await postInstall("ocr");
await waitFor(() => hoisted.spawnCalls.length === 1);
const r2 = await postInstall("face-detection");
expect(JSON.parse(r2.body).queued).toBe(true);
// The running install (ocr) finishes successfully.
hoisted.spawnCalls[0].emit("close", 0);
// The queued face-detection install auto-starts.
await waitFor(() => hoisted.spawnCalls.length === 2);
expect(hoisted.spawnCalls[1].bundleId).toBe("face-detection");
const bundles = await getFeatures();
expect(bundles.find((b) => b.id === "face-detection")?.status).toBe("installing");
// Cleanup: let the second install finish too.
hoisted.spawnCalls[1].emit("close", 0);
await waitFor(() => queue.getActiveBundleId() === null);
});
it("a bundle queued while an import holds the lock starts after the import route releases it", async () => {
// Simulate an offline import in progress by holding the install lock.
expect(acquireInstallLock("__import__")).toBe(true);
const res = await postInstall("ocr");
expect(res.statusCode).toBe(202);
expect(JSON.parse(res.body).queued).toBe(true);
// pump() could not acquire the held lock, so nothing spawned.
await tick();
expect(hoisted.spawnCalls.length).toBe(0);
const queuedBundles = await getFeatures();
expect(queuedBundles.find((b) => b.id === "ocr")?.status).toBe("queued");
// The import finishes and releases the lock; a subsequent import request's
// `finally { pump() }` then picks up the still-queued bundle.
releaseInstallLock();
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "bad.tar.gz",
contentType: "application/gzip",
content: Buffer.from("not a real tarball"),
},
]);
const importRes = await app.inject({
method: "POST",
url: "/api/v1/admin/features/import",
headers: { ...auth(), "content-type": contentType },
payload: body,
});
expect(importRes.statusCode).toBeGreaterThanOrEqual(400);
// The import route's finally pumped the queue -> ocr now installs.
await waitFor(() => hoisted.spawnCalls.length === 1);
expect(hoisted.spawnCalls[0].bundleId).toBe("ocr");
// Cleanup.
hoisted.spawnCalls[0].emit("close", 0);
await waitFor(() => queue.getActiveBundleId() === null);
});
});
@@ -131,6 +131,78 @@ describe("extract-zip (pure JS, no skipIf)", () => {
expect(parsed.error).toMatch(/unsafe entry path|invalid relative path/i);
}, 30_000);
it("rejects a zip with a deeply nested traversal entry (../../../etc/passphrase-lol) with 400", async () => {
// Same binary-patch technique as the "../evil.txt" case above, but with a
// multi-segment ".." path to make sure the segment-splitting check
// (name.split(/[/\\]/).some(s => s === "..")) isn't fooled by extra
// "../" hops -- only the first ".." segment mattering would be a bug.
const zip = new AdmZip();
const traversalName = "../../../etc/passphrase-lol";
const placeholder = "X".repeat(traversalName.length);
zip.addFile(placeholder, Buffer.from("evil"));
const zipBuf = Buffer.from(zip.toBuffer());
const placeholderBuf = Buffer.from(placeholder);
const replacementBuf = Buffer.from(traversalName);
let offset = zipBuf.indexOf(placeholderBuf);
while (offset !== -1) {
replacementBuf.copy(zipBuf, offset);
offset = zipBuf.indexOf(placeholderBuf, offset + 1);
}
const res = await runExtract("deep-traversal.zip", zipBuf);
expect(res.statusCode).toBe(400);
const parsed = JSON.parse(res.body);
expect(parsed.error).toMatch(/unsafe entry path|invalid relative path/i);
}, 30_000);
it("rejects a zip with an absolute Unix path entry (/etc/passwd) with 400", async () => {
// Absolute paths bypass ".." detection entirely -- extract-zip must reject
// entry names starting with "/" on their own (preValidate's
// name.startsWith("/") branch), independent of the ".." segment check.
const zip = new AdmZip();
const absoluteName = "/etc/passwd";
const placeholder = "X".repeat(absoluteName.length);
zip.addFile(placeholder, Buffer.from("evil"));
const zipBuf = Buffer.from(zip.toBuffer());
const placeholderBuf = Buffer.from(placeholder);
const replacementBuf = Buffer.from(absoluteName);
let offset = zipBuf.indexOf(placeholderBuf);
while (offset !== -1) {
replacementBuf.copy(zipBuf, offset);
offset = zipBuf.indexOf(placeholderBuf, offset + 1);
}
const res = await runExtract("absolute-path.zip", zipBuf);
expect(res.statusCode).toBe(400);
const parsed = JSON.parse(res.body);
expect(parsed.error).toMatch(/unsafe entry path|invalid relative path|absolute path/i);
}, 30_000);
it("rejects a zip with a Windows-style absolute path entry (\\evil.txt) with 400", async () => {
// Covers preValidate's name.startsWith("\\") branch, distinct from both
// the Unix absolute-path and ".." segment checks.
const zip = new AdmZip();
const absoluteName = "\\evil.txt";
const placeholder = "X".repeat(absoluteName.length);
zip.addFile(placeholder, Buffer.from("evil"));
const zipBuf = Buffer.from(zip.toBuffer());
const placeholderBuf = Buffer.from(placeholder);
const replacementBuf = Buffer.from(absoluteName);
let offset = zipBuf.indexOf(placeholderBuf);
while (offset !== -1) {
replacementBuf.copy(zipBuf, offset);
offset = zipBuf.indexOf(placeholderBuf, offset + 1);
}
const res = await runExtract("windows-absolute-path.zip", zipBuf);
expect(res.statusCode).toBe(400);
const parsed = JSON.parse(res.body);
expect(parsed.error).toMatch(/unsafe entry path|invalid relative path|absolute path/i);
}, 30_000);
it("rejects a high-ratio zip bomb with 422", async () => {
// Create a 60 MiB zero buffer - compresses to a very small zip
const bomb = Buffer.alloc(60 * 1024 * 1024, 0);
@@ -86,14 +86,20 @@ describe.skipIf(!sofficeAvailable())("convert-document (requires soffice)", () =
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
it("rejects same-format conversion (docx to docx)", async () => {
it("passes through same-format conversion (docx to docx)", async () => {
const res = await runTool("tiny.docx", DOCX, { format: "docx" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("failed");
const error = row?.error as { message: string } | null;
expect(error?.message).toMatch(/already in that format/i);
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
// Same-format is a passthrough: the original validated DOCX (PK magic) comes back.
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
});
+36 -21
View File
@@ -601,7 +601,7 @@ describe("Compose", () => {
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toContain("my-photo.png");
expect(result.downloadUrl).toContain("my-photo_composed.png");
});
it("rejects negative x position", async () => {
@@ -648,8 +648,8 @@ describe("Compose", () => {
// ── Branch coverage: multipart parse error (lines 60-64) ────────────
it("returns 400 for corrupt base image that fails processing", async () => {
// Send a corrupt buffer that passes initial multipart parse but fails Sharp processing
it("returns 400 for corrupt base image rejected by input validation", async () => {
// Send a corrupt buffer that passes multipart parsing but fails image validation.
const corruptBuffer = Buffer.from("not a real image content at all!!!");
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "base.png", contentType: "image/png", content: corruptBuffer },
@@ -667,16 +667,16 @@ describe("Compose", () => {
body,
});
// Should get 422 because the corrupt buffer fails Sharp processing
expect(res.statusCode).toBe(422);
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/processing failed/i);
expect(result.error).toMatch(/invalid image/i);
});
// ── Branch coverage: overlay larger than base causes 422 (line 140-144) ──
// ── Branch coverage: overlay larger than base is cropped to fit ─────
it("returns 422 when overlay is larger than base image", async () => {
// Overlay (200x150) is larger than base (100x100) — Sharp composite fails
it("crops overlay when it is larger than the base image", async () => {
// Overlay (200x150) is larger than base (100x100), so compose crops
// the overlay to the visible base area.
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "base.jpg", contentType: "image/jpeg", content: JPG },
{ name: "overlay", filename: "overlay.png", contentType: "image/png", content: PNG },
@@ -693,14 +693,21 @@ describe("Compose", () => {
body,
});
expect(res.statusCode).toBe(422);
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/processing failed/i);
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
const meta = await sharp(dlRes.rawPayload).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
// ── Branch coverage: 1x1 tiny image handling ────────────────────────
it("returns 422 when 1x1 base is smaller than overlay", async () => {
it("crops overlay when 1x1 base is smaller than overlay", async () => {
const TINY = readFixture(fixtures.image.edge.px1);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "base.png", contentType: "image/png", content: TINY },
@@ -718,10 +725,17 @@ describe("Compose", () => {
body,
});
// Overlay (100x100) extends beyond 1x1 base — Sharp fails
expect(res.statusCode).toBe(422);
// Overlay (100x100) extends beyond 1x1 base and should be cropped.
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/processing failed/i);
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
const meta = await sharp(dlRes.rawPayload).metadata();
expect(meta.width).toBe(1);
expect(meta.height).toBe(1);
});
it("handles 1x1 pixel overlay image", async () => {
@@ -985,7 +999,7 @@ describe("Compose", () => {
// ── Branch coverage: corrupt overlay ───────────────────────────────
it("returns 422 for corrupt overlay image", async () => {
it("returns 400 for corrupt overlay image rejected by input validation", async () => {
const corruptBuffer = Buffer.from("not a valid image at all");
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "base.png", contentType: "image/png", content: PNG },
@@ -1008,9 +1022,9 @@ describe("Compose", () => {
body,
});
expect(res.statusCode).toBe(422);
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/processing failed/i);
expect(result.error).toMatch(/invalid image/i);
});
// ── HEIF format input ─────────────────────────────────────────────
@@ -1177,7 +1191,7 @@ describe("Compose", () => {
// ── X exceeding max rejects ───────────────────────────────────────
it("rejects x position exceeding 65535", async () => {
it("rejects x position outside the base image", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "base.png", contentType: "image/png", content: PNG },
{ name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG },
@@ -1194,8 +1208,9 @@ describe("Compose", () => {
body,
});
// Sharp composite will fail if overlay extends beyond canvas
expect([200, 422]).toContain(res.statusCode);
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/outside the base image/i);
});
// ── AVIF format input ─────────────────────────────────────────────
+281 -42
View File
@@ -7,7 +7,7 @@
* Usage:
* ./apps/api/node_modules/.bin/tsx tests/qa/api-sweep.mts
*
* Expects: snapotter-qa container at http://localhost:13499, AUTH_ENABLED=false.
* Expects: snapotter-qa container at QA_BASE_URL or http://localhost:13499, AUTH_ENABLED=false.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -15,7 +15,12 @@ import { join } from "node:path";
import { apiToolPath } from "../../packages/shared/src/constants.js";
// ── Config ────────────────────────────────────────────────────────
const BASE = "http://localhost:13499";
// biome-ignore lint/suspicious/noUndeclaredEnvVars: QA scripts are run directly, outside Turbo.
const BASE = process.env.QA_BASE_URL || "http://localhost:13499";
// biome-ignore lint/suspicious/noUndeclaredEnvVars: QA scripts are run directly, outside Turbo.
const TOOL_FILTER = new Set((process.env.QA_TOOL_FILTER || "").split(",").filter(Boolean));
// biome-ignore lint/suspicious/noUndeclaredEnvVars: QA scripts are run directly, outside Turbo.
const FORMAT_FILTER = new Set((process.env.QA_FORMAT_FILTER || "").split(",").filter(Boolean));
const REPO = join(import.meta.dirname, "..", "..");
const FIXTURES_FORMATS = join(REPO, "tests", "fixtures", "image", "formats");
const FIXTURES_MEDIA_VIDEO = join(REPO, "tests", "fixtures", "video", "formats");
@@ -48,14 +53,6 @@ interface SweepResult {
note: string;
}
type Classification =
| "pass"
| "expected-reject"
| "suspicious-reject"
| "bug"
| "skipped"
| "needs-review";
// ── Load tools + settings ─────────────────────────────────────────
const tools: ToolMeta[] = JSON.parse(
@@ -64,11 +61,13 @@ const tools: ToolMeta[] = JSON.parse(
const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
resize: { width: 64 },
crop: { left: 0, top: 0, width: 50, height: 50 },
crop: { left: 0, top: 0, width: 4, height: 4 },
convert: { format: "png" },
"watermark-text": { text: "Test" },
"text-overlay": { text: "Test" },
"passport-photo": { countryCode: "us" },
"content-aware-resize": { width: 64 },
collage: { templateId: "2-h-equal" },
"trim-video": { startS: 0, endS: 5 },
"trim-audio": { startS: 0, endS: 5 },
"split-pdf": { mode: "range", range: "1" },
@@ -84,6 +83,7 @@ const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
"resize-video": { preset: "720p" },
"watermark-video": { text: "CONFIDENTIAL" },
"audio-channels": { mode: "mono-to-stereo" },
"split-audio": { mode: "parts", parts: 2 },
"convert-document": { format: "odt" },
"epub-convert": { format: "html" },
"convert-presentation": { format: "odp" },
@@ -94,6 +94,107 @@ function defaultSettingsFor(toolId: string): unknown {
return TOOL_SETTINGS_OVERRIDES[toolId] ?? {};
}
const CUSTOM_BODY_SETTINGS: Record<string, unknown> = {
"qr-generate": { text: "SnapOtter QA" },
"barcode-generate": { text: "SnapOtter QA", type: "code128" },
"html-to-image": { html: "<html><body><h1>SnapOtter QA</h1></body></html>", format: "png" },
};
interface SecondaryInput {
fieldName: string;
ext?: string;
modality?: string;
sameAsPrimary?: boolean;
}
const AUDIO_FORMATS = new Set([
".mp3",
".wav",
".flac",
".aac",
".m4a",
".ogg",
".opus",
".wma",
".aiff",
".amr",
".ac3",
]);
const SUBTITLE_FORMATS = new Set([".srt", ".vtt", ".ass"]);
const SECONDARY_INPUTS: Record<string, SecondaryInput[]> = {
"watermark-image": [{ fieldName: "watermark", ext: ".png", modality: "image" }],
compose: [{ fieldName: "overlay", ext: ".png", modality: "image" }],
compare: [{ fieldName: "file", sameAsPrimary: true }],
"find-duplicates": [{ fieldName: "file", sameAsPrimary: true }],
collage: [{ fieldName: "file", sameAsPrimary: true }],
stitch: [{ fieldName: "file", sameAsPrimary: true }],
"sprite-sheet": [{ fieldName: "file", sameAsPrimary: true }],
"images-to-video": [{ fieldName: "file", sameAsPrimary: true }],
"merge-videos": [{ fieldName: "file", sameAsPrimary: true }],
"merge-audio": [{ fieldName: "file", sameAsPrimary: true }],
"merge-pdf": [{ fieldName: "file", sameAsPrimary: true }],
"merge-csvs": [{ fieldName: "file", sameAsPrimary: true }],
"replace-audio": [{ fieldName: "file", ext: ".mp3", modality: "audio" }],
"burn-subtitles": [{ fieldName: "file", ext: ".srt", modality: "video" }],
"embed-subtitles": [{ fieldName: "file", ext: ".srt", modality: "video" }],
"sign-pdf": [{ fieldName: "sig0", ext: ".png", modality: "image" }],
};
const TOOL_SPECIFIC_FIXTURES: Record<string, Record<string, string>> = {
"chart-maker": {
".json": join(FIXTURES_DATA, "chart.json"),
},
};
const EXPECTED_SELF_REJECTS: Record<string, RegExp[]> = {
"extract-subtitles": [/no subtitle track/i],
};
function resolveFixtureForTool(tool: ToolMeta, ext: string): string | null {
const toolFixture = TOOL_SPECIFIC_FIXTURES[tool.id]?.[ext];
if (toolFixture && existsSync(toolFixture)) {
return toolFixture;
}
return resolveFixture(ext, tool.modality);
}
function isExpectedSelfReject(toolId: string, message: string): boolean {
return EXPECTED_SELF_REJECTS[toolId]?.some((pattern) => pattern.test(message)) ?? false;
}
function isSecondaryOnlyFormat(toolId: string, ext: string): boolean {
if (toolId === "replace-audio") return AUDIO_FORMATS.has(ext);
if (toolId === "burn-subtitles" || toolId === "embed-subtitles") {
return SUBTITLE_FORMATS.has(ext);
}
return false;
}
function secondaryInputsFor(
tool: ToolMeta,
ext: string,
): Array<{ fieldName: string; fixture: string }> {
const inputs = SECONDARY_INPUTS[tool.id] ?? [];
const resolved: Array<{ fieldName: string; fixture: string }> = [];
for (const input of inputs) {
const secondaryExt = input.sameAsPrimary ? ext : input.ext;
const secondaryModality = input.sameAsPrimary
? tool.modality
: (input.modality ?? tool.modality);
if (!secondaryExt) continue;
const fixture = resolveFixture(secondaryExt, secondaryModality);
if (fixture) {
resolved.push({ fieldName: input.fieldName, fixture });
}
}
return resolved;
}
// ── Extension aliases ─────────────────────────────────────────────
const EXT_ALIASES: Record<string, string> = {
".jpeg": ".jpg",
@@ -159,21 +260,6 @@ function resolveFixture(ext: string, modality: string): string | null {
return null;
}
// ── Multipart builder (native, no deps) ───────────────────────────
function buildMultipart(
filePath: string,
filename: string,
settings: unknown,
): { body: Blob; contentType: string } {
const fileBytes = readFileSync(filePath);
const form = new FormData();
form.append("file", new Blob([fileBytes]), filename);
form.append("settings", JSON.stringify(settings));
// Return the FormData directly -- fetch handles it
return { body: form as unknown as Blob, contentType: "multipart/form-data" };
}
// ── Output verification ───────────────────────────────────────────
/** Known file signatures (magic bytes). */
@@ -279,6 +365,17 @@ function verifyOutput(data: Buffer, contentType: string): { ok: boolean; detail:
return { ok: false, detail: `suspiciously small binary output (${data.length} bytes)` };
}
async function fetchAndVerifyDownload(
downloadUrl: string,
): Promise<{ ok: boolean; detail: string }> {
const dlRes = await fetch(`${BASE}${downloadUrl}`);
if (!dlRes.ok) return { ok: false, detail: `downloadUrl returned ${dlRes.status}` };
const outBuf = Buffer.from(await dlRes.arrayBuffer());
const outCT = dlRes.headers.get("content-type") || "";
return verifyOutput(outBuf, outCT);
}
// ── SSE polling for async jobs ────────────────────────────────────
async function pollJobSSE(
@@ -373,7 +470,7 @@ async function pollJobSSE(
} finally {
reader.cancel().catch(() => {});
}
} catch (err) {
} catch (_err) {
if (Date.now() >= deadline) break;
// Connection error -- retry after a short wait
await sleep(SSE_POLL_INTERVAL_MS);
@@ -487,7 +584,7 @@ async function main() {
const health = await fetch(`${BASE}/api/v1/health`);
if (!health.ok) throw new Error(`health check returned ${health.status}`);
console.log("Container health: OK\n");
} catch (err) {
} catch (_err) {
console.error("ERROR: Cannot reach container at", BASE);
process.exit(1);
}
@@ -505,15 +602,120 @@ async function main() {
const startTime = Date.now();
for (const tool of tools) {
const formats = tool.isAI ? [aiRepresentativeFormat(tool)] : [...tool.acceptedInputs]; // clone to avoid mutation
const selectedTools =
TOOL_FILTER.size > 0 ? tools.filter((tool) => TOOL_FILTER.has(tool.id)) : tools;
for (const tool of selectedTools) {
const formats = CUSTOM_BODY_SETTINGS[tool.id]
? [".custom-body"]
: tool.isAI
? [aiRepresentativeFormat(tool)]
: [...tool.acceptedInputs]; // clone to avoid mutation
// Deduplicate aliases (e.g. .jpg and .jpeg resolve to same fixture)
const seenFixtures = new Set<string>();
for (const ext of formats) {
if (FORMAT_FILTER.size > 0 && !FORMAT_FILTER.has(ext)) continue;
totalCombos++;
const fixture = resolveFixture(ext, tool.modality);
const customBodySettings = CUSTOM_BODY_SETTINGS[tool.id];
if (customBodySettings) {
console.log(` [TEST] ${tool.id} x custom-body...`);
try {
const res = await fetch(`${BASE}${apiToolPath(tool.id)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(customBodySettings),
});
const statusCode = res.status;
if (statusCode >= 400) {
let body = "";
try {
body = await res.text();
} catch {}
const r: SweepResult = {
tool: tool.id,
format: ext,
status: statusCode,
outputOk: false,
note: `BUG: custom body route returned ${statusCode} -- ${body.slice(0, 300)}`,
};
results.push(r);
bugs.push(r);
bugCount++;
console.log(` [BUG] ${statusCode}: ${body.slice(0, 100)}`);
continue;
}
const json = (await res.json()) as Record<string, unknown>;
const downloadUrl = json.downloadUrl as string | undefined;
if (!downloadUrl) {
const r: SweepResult = {
tool: tool.id,
format: ext,
status: statusCode,
outputOk: Object.keys(json).length > 0,
note: `pass: JSON result (keys: ${Object.keys(json).join(",")})`,
};
results.push(r);
passes++;
console.log(` [PASS] JSON result (${Object.keys(json).join(",")})`);
continue;
}
const verification = await fetchAndVerifyDownload(downloadUrl);
const r: SweepResult = {
tool: tool.id,
format: ext,
status: statusCode,
outputOk: verification.ok,
note: verification.ok
? `pass: ${verification.detail}`
: `BUG: corrupt success -- ${verification.detail}`,
};
results.push(r);
if (verification.ok) {
passes++;
console.log(` [PASS] ${verification.detail}`);
} else {
bugs.push(r);
bugCount++;
console.log(` [BUG] corrupt output: ${verification.detail}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const r: SweepResult = {
tool: tool.id,
format: ext,
status: "network-error",
outputOk: false,
note: `BUG: custom body request failed -- ${msg.slice(0, 200)}`,
};
results.push(r);
bugs.push(r);
bugCount++;
console.log(` [BUG] custom body request failed: ${msg.slice(0, 80)}`);
}
continue;
}
if (isSecondaryOnlyFormat(tool.id, ext)) {
const r: SweepResult = {
tool: tool.id,
format: ext,
status: "secondary-only",
outputOk: null,
note: "skipped: secondary-only format for multi-input route",
};
results.push(r);
skipped++;
console.log(` [SKIP] ${tool.id} x ${ext}: secondary-only format`);
continue;
}
const fixture = resolveFixtureForTool(tool, ext);
if (!fixture) {
const r: SweepResult = {
@@ -550,7 +752,7 @@ async function main() {
? LONG_TIMEOUT_MS
: FAST_TIMEOUT_MS;
const settings = defaultSettingsFor(tool.id);
const filename = fixture.split("/").pop()!;
const filename = fixture.split("/").pop() ?? "input";
console.log(` [TEST] ${tool.id} x ${ext} (${filename})...`);
@@ -558,7 +760,18 @@ async function main() {
const form = new FormData();
const fileBytes = readFileSync(fixture);
form.append("file", new Blob([fileBytes]), filename);
form.append("settings", JSON.stringify(settings));
for (const input of secondaryInputsFor(tool, ext)) {
const secondaryFilename = input.fixture.split("/").pop() ?? "secondary";
form.append(input.fieldName, new Blob([readFileSync(input.fixture)]), secondaryFilename);
}
if (tool.id === "sign-pdf") {
form.append(
"placements",
JSON.stringify([{ sig: 0, page: 0, x: 0.1, y: 0.1, w: 0.25, h: 0.12 }]),
);
} else {
form.append("settings", JSON.stringify(settings));
}
const controller = new AbortController();
const fetchTimer = setTimeout(() => controller.abort(), timeoutMs);
@@ -603,10 +816,13 @@ async function main() {
parsed = JSON.parse(body);
} catch {}
const msg = parsed.error || parsed.details || body.slice(0, 200);
const fullMsg =
[parsed.error, parsed.details].filter(Boolean).join(" -- ") || body.slice(0, 300);
// Check if this format is in the tool's own acceptedInputs
const isSelfFormat = tool.acceptedInputs.includes(ext);
const classification = isSelfFormat ? "suspicious-reject" : "expected-reject";
const isExpected = !isSelfFormat || isExpectedSelfReject(tool.id, fullMsg);
const classification = isExpected ? "expected-reject" : "suspicious-reject";
const r: SweepResult = {
tool: tool.id,
@@ -617,7 +833,7 @@ async function main() {
};
results.push(r);
if (isSelfFormat) {
if (!isExpected) {
suspicious.push(r);
suspiciousCount++;
console.log(` [SUSPICIOUS] ${statusCode}: ${msg}`);
@@ -634,6 +850,28 @@ async function main() {
try {
body = await res.text();
} catch {}
let parsed: { code?: string; feature?: string; featureName?: string; error?: string } =
{};
try {
parsed = JSON.parse(body);
} catch {}
if (statusCode === 501 && parsed.code === "FEATURE_NOT_INSTALLED") {
const feature = parsed.featureName || parsed.feature || "AI feature";
const r: SweepResult = {
tool: tool.id,
format: ext,
status: statusCode,
outputOk: null,
note: `skipped-feature-not-installed: ${feature}`,
};
results.push(r);
skipped++;
console.log(` [SKIP] feature not installed: ${feature}`);
continue;
}
const r: SweepResult = {
tool: tool.id,
format: ext,
@@ -678,7 +916,7 @@ async function main() {
let json: Record<string, unknown>;
try {
json = (await res.json()) as Record<string, unknown>;
} catch (e) {
} catch (_e) {
const r: SweepResult = {
tool: tool.id,
format: ext,
@@ -698,7 +936,7 @@ async function main() {
// Some tools return JSON results directly (info, color-palette, barcode-read, image-to-base64, etc.)
if (!downloadUrl) {
// Check if it's a result-only response (no downloadUrl but has data)
if (Object.keys(json).length > 0 && json.jobId) {
if (Object.keys(json).length > 0) {
const r: SweepResult = {
tool: tool.id,
format: ext,
@@ -831,8 +1069,9 @@ async function main() {
continue;
}
const asyncTimeoutMs = Math.max(timeoutMs, LONG_TIMEOUT_MS);
console.log(` [ASYNC] jobId=${jobId}, polling SSE...`);
const jobResult = await pollJobSSE(jobId, timeoutMs);
const jobResult = await pollJobSSE(jobId, asyncTimeoutMs);
if (jobResult.status === "timeout") {
const r: SweepResult = {
@@ -840,7 +1079,7 @@ async function main() {
format: ext,
status: "202-timeout",
outputOk: false,
note: `BUG: async job timed out after ${timeoutMs}ms`,
note: `BUG: async job timed out after ${asyncTimeoutMs}ms`,
};
results.push(r);
bugs.push(r);
@@ -1003,7 +1242,7 @@ async function main() {
const byTool = new Map<string, SweepResult[]>();
for (const b of bugs) {
if (!byTool.has(b.tool)) byTool.set(b.tool, []);
byTool.get(b.tool)!.push(b);
byTool.get(b.tool)?.push(b);
}
for (const [toolId, toolBugs] of byTool) {
@@ -1022,7 +1261,7 @@ async function main() {
const byTool = new Map<string, SweepResult[]>();
for (const s of suspicious) {
if (!byTool.has(s.tool)) byTool.set(s.tool, []);
byTool.get(s.tool)!.push(s);
byTool.get(s.tool)?.push(s);
}
for (const [toolId, toolSuspicious] of byTool) {
@@ -1038,7 +1277,7 @@ async function main() {
// ── Console summary ───────────────────────────────────────────
console.log("\n" + "=".repeat(60));
console.log(`\n${"=".repeat(60)}`);
console.log("SWEEP COMPLETE");
console.log("=".repeat(60));
console.log(`Total combos: ${totalCombos}`);
+99
View File
@@ -0,0 +1,99 @@
// Drives the REAL in-app AI bundle install flow (Settings > AI Features > Install
// All) against a running Docker container, exactly as a user would click through
// it. This is the primary AI-install path per docs/prompts/engineering/QA_PROMPT.md
// Phase 2 -- the curl-based /api/v1/admin/features/<bundle>/install route is a
// verification/fallback path only, never the primary install.
//
// This script only KICKS OFF the install and captures pre/mid-install evidence; the
// installs continue server-side once triggered. Poll status separately with:
// curl -s $QA_BASE_URL/api/v1/features -H "Authorization: Bearer $TOKEN" | jq
// then run verify-ai-install-complete.mts once every bundle reports installed.
//
// Usage:
// QA_BASE_URL=http://localhost:13599 QA_USERNAME=admin QA_PASSWORD=admin \
// apps/api/node_modules/.bin/tsx tests/qa/install-ai-bundles-ui.mts
import fs from "node:fs";
import path from "node:path";
import { chromium } from "@playwright/test";
const BASE = process.env.QA_BASE_URL || "http://localhost:13499";
const USERNAME = process.env.QA_USERNAME || "admin";
const PASSWORD = process.env.QA_PASSWORD || "admin";
const SHOT_DIR = path.join("tests", "e2e", "screenshots", "qa", "ai-install");
fs.mkdirSync(SHOT_DIR, { recursive: true });
async function shot(page: import("@playwright/test").Page, name: string) {
const file = path.join(SHOT_DIR, `${name}.png`);
await page.screenshot({ path: file, fullPage: true });
console.log(` screenshot: ${file}`);
}
async function main() {
const browser = await chromium.launch({ channel: "chrome" });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
// Don't interpolate the env-derived base URL / username into the log
// (clear-text logging of environment values is flagged by static analysis).
console.log("Logging in...");
await page.goto(BASE);
await page.waitForLoadState("networkidle").catch(() => {});
if (page.url().includes("/login")) {
await page.locator("#username").waitFor({ timeout: 10_000 });
await page.locator("#username").fill(USERNAME);
await page.locator("#password").fill(PASSWORD);
await page.getByRole("button", { name: /^log ?in$/i }).click();
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 15_000 });
} else {
console.log(" already authenticated (no login form shown)");
}
// Bail loudly if we land on the forced password-change screen -- the caller
// should set SKIP_MUST_CHANGE_PASSWORD=true for automated runs.
if (page.url().includes("/change-password")) {
throw new Error(
"Landed on /change-password -- set SKIP_MUST_CHANGE_PASSWORD=true on the container for automated QA runs",
);
}
console.log("Opening Settings > AI Features...");
await page.getByRole("button", { name: USERNAME, exact: true }).click();
await page.getByText("Settings", { exact: true }).click();
await page.getByText("AI Features", { exact: true }).click();
await page.waitForTimeout(1000);
await shot(page, "01-pre-install");
// Record pre-install state from the DOM text (cheap sanity check the download
// is provably on-demand, not pre-baked).
const preText = await page.locator("body").innerText();
const notInstalledCount = (preText.match(/Not installed/g) || []).length;
console.log(` bundles showing "Not installed" before click: ${notInstalledCount}`);
console.log("Clicking Install All...");
const installAllBtn = page.getByRole("button", { name: /install all/i });
await installAllBtn.click();
await page.waitForTimeout(3000);
await shot(page, "02-mid-install-immediate");
// Give the queue a bit longer to actually start downloading before the second
// "mid-download" screenshot the prompt asks for.
await page.waitForTimeout(30_000);
await shot(page, "03-mid-install-30s");
const midText = await page.locator("body").innerText();
const installingCount = (midText.match(/\d+%/g) || []).length;
const queuedCount = (midText.match(/Queued/g) || []).length;
console.log(` bundles showing a % progress at +33s: ${installingCount}`);
console.log(` bundles showing "Queued" at +33s: ${queuedCount}`);
console.log(
"Install kicked off and left running server-side. Poll /api/v1/features until every bundle is installed, then run verify-ai-install-complete.mts.",
);
await browser.close();
}
main().catch((err) => {
console.error("FAILED:", err);
process.exit(1);
});
+3 -6
View File
@@ -6,7 +6,7 @@
* downloadable result, and no console errors.
*/
import { expect, type Page, test } from "@playwright/test";
import { fixture, instrument, isClean, issuesSummary } from "./qa-helpers";
import { fixture, instrument, isClean, issuesSummary, uploadFiles } from "./qa-helpers";
const FIXTURE_PNG = fixture("image", "formats", "sample.png");
@@ -44,11 +44,8 @@ test.describe("Pipeline Builder UI", () => {
await page.goto("/automate", { waitUntil: "domcontentloaded" });
await expect(page.locator("text=Pipeline Builder")).toBeVisible({ timeout: 15_000 });
// ---- Upload a file via the dropzone ----
const chooserPromise = page.waitForEvent("filechooser");
await page.locator("[class*='border-dashed']").first().click();
const chooser = await chooserPromise;
await chooser.setFiles(FIXTURE_PNG);
// ---- Upload a file via the semantic upload control ----
await uploadFiles(page, FIXTURE_PNG);
// Wait for file badge
await expect(page.locator("text=sample.png").first()).toBeVisible({ timeout: 10_000 });
+71 -3
View File
@@ -12,9 +12,56 @@ import { expect, type Page } from "@playwright/test";
export const REPO_ROOT = path.join(__dirname, "..", "..");
export const FIXTURES = path.join(REPO_ROOT, "tests", "fixtures");
/** Absolute path to a fixture, e.g. fixture("formats", "sample.png"). */
/** Absolute path to a fixture. Accepts current paths and legacy QA aliases. */
export function fixture(...parts: string[]): string {
return path.join(FIXTURES, ...parts);
const direct = path.join(FIXTURES, ...parts);
if (fs.existsSync(direct)) return direct;
const [scope, ...rest] = parts;
const legacyScopes: Record<string, string[]> = {
content: [path.join("image", "valid"), path.join("document", "valid")],
formats: [path.join("image", "formats"), path.join("image", "valid")],
media: [
path.join("video", "formats"),
path.join("video", "valid"),
path.join("audio", "formats"),
path.join("audio", "valid"),
],
documents: [
path.join("document", "formats"),
path.join("document", "valid"),
path.join("document", "edge"),
path.join("document", "hostile"),
],
data: [path.join("data", "valid")],
};
for (const candidateScope of legacyScopes[scope] ?? []) {
const candidate = path.join(FIXTURES, candidateScope, ...rest);
if (fs.existsSync(candidate)) return candidate;
}
if (parts.length === 1) {
const legacyBareScopes = [
path.join("image", "valid"),
path.join("image", "edge"),
path.join("image", "formats"),
path.join("document", "valid"),
path.join("document", "formats"),
path.join("document", "edge"),
path.join("video", "valid"),
path.join("video", "formats"),
path.join("audio", "valid"),
path.join("audio", "formats"),
path.join("data", "valid"),
];
for (const candidateScope of legacyBareScopes) {
const candidate = path.join(FIXTURES, candidateScope, parts[0]);
if (fs.existsSync(candidate)) return candidate;
}
}
return direct;
}
// ---------------------------------------------------------------------------
@@ -338,6 +385,24 @@ export interface ImageInfo {
hasAlpha: boolean;
}
function sharpHasAlpha(file: string): boolean {
try {
const script = `
import sharp from "sharp";
const meta = await sharp(process.argv[1]).metadata();
console.log(JSON.stringify({ hasAlpha: meta.hasAlpha === true }));
`;
const out = execFileSync(process.execPath, ["--input-type=module", "-e", script, file], {
cwd: path.join(REPO_ROOT, "apps", "api"),
encoding: "utf8",
timeout: 20_000,
});
return JSON.parse(out).hasAlpha === true;
} catch {
return false;
}
}
/** Image dimensions/format/alpha via ffprobe (png/jpg/webp/gif/tiff/bmp/avif/heic/...). */
export function imageInfo(file: string): ImageInfo {
const out = execFileSync(
@@ -357,12 +422,15 @@ export function imageInfo(file: string): ImageInfo {
);
const s = (JSON.parse(out).streams ?? [])[0] ?? {};
const pixFmt: string = s.pix_fmt ?? "";
const hasAlpha =
/(rgba|bgra|argb|abgr|ya\d|yuva|gbrap)/i.test(pixFmt) ||
(s.codec_name === "png" && sharpHasAlpha(file));
return {
width: s.width ?? 0,
height: s.height ?? 0,
codec: s.codec_name ?? "",
pixFmt,
hasAlpha: /(rgba|bgra|argb|abgr|ya\d|yuva|gbrap)/i.test(pixFmt),
hasAlpha,
};
}
+4
View File
@@ -1268,6 +1268,9 @@ test.describe("IMAGE: replace-color", () => {
await setupTool(page, "replace-color", IMG_200x150);
// Check the "make transparent" checkbox
await page.getByRole("checkbox").first().check();
// The current fixture has no pixels near pure red at the default tolerance.
// Use the full tolerance range so this assertion exercises transparent output.
await setSlider(page, "replace-tolerance", 255);
const dl = await processAndDownload(page, "replace-color");
if (!dl.ok) {
bug({
@@ -1288,6 +1291,7 @@ test.describe("IMAGE: replace-color", () => {
expected: "hasAlpha=true",
actual: `hasAlpha=${info.hasAlpha}`,
});
expect(info.hasAlpha).toBe(true);
});
test("tolerance=0 (exact match) -> runs", async ({ page }) => {
+198
View File
@@ -0,0 +1,198 @@
// Companion to install-ai-bundles-ui.mts: once every bundle reports installed via
// the API, screenshot the completed UI state and run one real tool per bundle to
// prove the freshly-downloaded model actually executes (not just "installed: true").
//
// Usage:
// QA_BASE_URL=http://localhost:13599 QA_USERNAME=admin QA_PASSWORD=admin \
// apps/api/node_modules/.bin/tsx tests/qa/verify-ai-install-complete.mts
import fs from "node:fs";
import path from "node:path";
import { chromium } from "@playwright/test";
import { apiToolPath } from "../../packages/shared/src/constants.js";
const BASE = process.env.QA_BASE_URL || "http://localhost:13499";
const USERNAME = process.env.QA_USERNAME || "admin";
const PASSWORD = process.env.QA_PASSWORD || "admin";
const SHOT_DIR = path.join("tests", "e2e", "screenshots", "qa", "ai-install");
fs.mkdirSync(SHOT_DIR, { recursive: true });
interface Bundle {
id: string;
status: string;
installedVersion: string | null;
}
async function login(): Promise<string> {
const res = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
});
const j = (await res.json()) as { token: string };
return j.token;
}
async function getBundles(token: string): Promise<Bundle[]> {
const res = await fetch(`${BASE}/api/v1/features`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = (await res.json()) as Bundle[] | { bundles: Bundle[] };
return Array.isArray(body) ? body : body.bundles;
}
const ONE_TOOL_PER_BUNDLE: Record<string, { toolId: string; file: string; settings: object }> = {
"background-removal": {
toolId: "remove-background",
file: "tests/fixtures/image/valid/portrait-color.jpg",
settings: { outputFormat: "png" },
},
"face-detection": {
toolId: "smart-crop",
file: "tests/fixtures/image/valid/multi-face.webp",
settings: { width: 200, height: 200 },
},
"object-eraser-colorize": {
toolId: "colorize",
file: "tests/fixtures/image/valid/portrait-bw.jpeg",
settings: {},
},
"upscale-enhance": {
toolId: "upscale",
file: "tests/fixtures/image/valid/test-100x100.jpg",
settings: { scale: 2 },
},
"photo-restoration": {
toolId: "restore-photo",
file: "tests/fixtures/image/valid/portrait-bw.jpeg",
settings: {},
},
ocr: { toolId: "ocr", file: "tests/fixtures/image/valid/ocr-clean.png", settings: {} },
transcription: {
toolId: "transcribe-audio",
file: "tests/fixtures/audio/valid/speech-10s.wav",
settings: { outputFormat: "txt" },
},
};
async function pollSSE(token: string, jobId: string, timeoutMs = 300_000) {
const res = await fetch(`${BASE}/api/v1/jobs/${jobId}/progress`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.body) return { error: "no body" };
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx: number;
// biome-ignore lint/suspicious/noAssignInExpressions: stream parse
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line.startsWith("data:")) continue;
try {
const d = JSON.parse(line.slice(5).trim());
if (d.phase === "complete" || d.status === "completed") {
await reader.cancel();
return d;
}
if (d.phase === "failed" || d.status === "failed") {
await reader.cancel();
return { failed: true, ...d };
}
} catch {
// partial line, keep buffering
}
}
}
await reader.cancel();
return { timeout: true };
}
async function runOneTool(token: string, bundleId: string) {
const spec = ONE_TOOL_PER_BUNDLE[bundleId];
if (!spec) {
console.log(` [${bundleId}] no smoke-tool mapping defined, skipping first-run check`);
return;
}
const buf = fs.readFileSync(spec.file);
const fd = new FormData();
fd.append("file", new Blob([buf]), path.basename(spec.file));
fd.append("settings", JSON.stringify(spec.settings));
const res = await fetch(`${BASE}${apiToolPath(spec.toolId)}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (res.status === 200) {
const j = (await res.json()) as { downloadUrl?: string };
console.log(` [${bundleId}] ${spec.toolId}: sync 200, downloadUrl=${!!j.downloadUrl}`);
return;
}
if (res.status === 202) {
const j = (await res.json()) as { jobId: string };
const done = await pollSSE(token, j.jobId);
if ((done as { failed?: boolean }).failed) {
console.log(` [${bundleId}] ${spec.toolId}: FAILED - ${JSON.stringify(done).slice(0, 200)}`);
} else if ((done as { timeout?: boolean }).timeout) {
console.log(` [${bundleId}] ${spec.toolId}: TIMED OUT waiting for completion`);
} else {
console.log(` [${bundleId}] ${spec.toolId}: async completed OK`);
}
return;
}
console.log(` [${bundleId}] ${spec.toolId}: unexpected status ${res.status}`);
}
async function main() {
const token = await login();
const bundles = await getBundles(token);
console.log("Bundle status:");
for (const b of bundles) console.log(` ${b.id}: ${b.status} (${b.installedVersion ?? "-"})`);
const notInstalled = bundles.filter((b) => b.status !== "installed");
if (notInstalled.length > 0) {
console.log(
`\n${notInstalled.length} bundle(s) not yet installed: ${notInstalled.map((b) => b.id).join(", ")}`,
);
console.log("Re-run this script once they finish installing.");
}
console.log("\nScreenshotting completed UI state...");
const browser = await chromium.launch({ channel: "chrome" });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(BASE);
await page.waitForLoadState("networkidle").catch(() => {});
if (page.url().includes("/login")) {
await page.locator("#username").waitFor({ timeout: 10_000 });
await page.locator("#username").fill(USERNAME);
await page.locator("#password").fill(PASSWORD);
await page.getByRole("button", { name: /^log ?in$/i }).click();
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 15_000 });
}
await page.getByRole("button", { name: USERNAME, exact: true }).click();
await page.getByText("Settings", { exact: true }).click();
await page.getByText("AI Features", { exact: true }).click();
await page.waitForTimeout(1000);
await page.screenshot({
path: path.join(SHOT_DIR, "04-post-install.png"),
fullPage: true,
});
console.log(` screenshot: ${path.join(SHOT_DIR, "04-post-install.png")}`);
await browser.close();
console.log("\nRunning one real tool per installed bundle...");
for (const b of bundles.filter((x) => x.status === "installed")) {
await runOneTool(token, b.id);
}
console.log("\nDONE");
}
main().catch((err) => {
console.error("FAILED:", err);
process.exit(1);
});
+2 -1
View File
@@ -5,7 +5,8 @@ import fs from "node:fs";
import path from "node:path";
import { apiToolPath } from "../../packages/shared/src/constants.js";
const BASE = "http://localhost:13499";
// biome-ignore lint/suspicious/noUndeclaredEnvVars: QA scripts are run directly, outside Turbo.
const BASE = process.env.QA_BASE_URL || "http://localhost:13499";
async function pollSSE(jobId: string, timeoutMs = 240_000): Promise<Record<string, unknown>> {
const res = await fetch(`${BASE}/api/v1/jobs/${jobId}/progress`);
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { friendlyError } from "../../../apps/api/src/lib/errors.js";
import { friendlyError, stripControlChars } from "../../../apps/api/src/lib/errors.js";
const GENERIC = "Processing failed. The file may be in an unsupported or corrupted format.";
@@ -62,4 +62,24 @@ describe("friendlyError", () => {
const ok = "Region exceeds image bounds";
expect(friendlyError(friendlyError(ok))).toBe(ok);
});
it("strips ANSI/terminal control chars from surfaced subprocess errors", () => {
// caire's progress spinner emits ANSI cursor/color sequences into stderr.
const raw = "\x1B[2K\x1B[1G\x1B[36mcarving\x1B[0m 42%\x08\x08done";
expect(friendlyError(raw)).toBe("carving 42%done");
});
});
describe("stripControlChars", () => {
it("removes ANSI CSI sequences but keeps visible text", () => {
expect(stripControlChars("\x1B[31mred\x1B[0m text")).toBe("red text");
});
it("removes residual C0 control chars but preserves tab and newline", () => {
expect(stripControlChars("a\x00b\x07c\td\ne")).toBe("abc\td\ne");
});
it("leaves plain text and accented locale strings untouched", () => {
expect(stripControlChars("Café déjà vu")).toBe("Café déjà vu");
});
});
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
clearActive,
dequeue,
enqueue,
getActiveBundleId,
getQueuedBundleIds,
isQueuedOrActive,
peekQueue,
resetQueueState,
setActive,
} from "../../../apps/api/src/lib/feature-install-queue.js";
describe("feature-install-queue", () => {
beforeEach(() => resetQueueState());
afterEach(() => resetQueueState());
it("starts empty", () => {
expect(getActiveBundleId()).toBeNull();
expect(getQueuedBundleIds()).toEqual([]);
expect(peekQueue()).toBeNull();
});
it("enqueue appends and returns the new jobId", () => {
const jobId = enqueue({ bundleId: "ocr", jobId: "job-1" });
expect(jobId).toBe("job-1");
expect(getQueuedBundleIds()).toEqual(["ocr"]);
});
it("preserves FIFO order across multiple bundles", () => {
enqueue({ bundleId: "ocr", jobId: "j1" });
enqueue({ bundleId: "face-detection", jobId: "j2" });
enqueue({ bundleId: "transcription", jobId: "j3" });
expect(getQueuedBundleIds()).toEqual(["ocr", "face-detection", "transcription"]);
});
it("dedups an already-queued bundle and returns the existing jobId", () => {
enqueue({ bundleId: "ocr", jobId: "j1" });
const second = enqueue({ bundleId: "ocr", jobId: "j2-different" });
// No second entry added; the original job's id is returned so the client
// attaches to the in-flight job instead of spawning a duplicate.
expect(second).toBe("j1");
expect(getQueuedBundleIds()).toEqual(["ocr"]);
});
it("dedups against the active install and returns the active jobId", () => {
setActive({ bundleId: "ocr", jobId: "active-job" });
const jobId = enqueue({ bundleId: "ocr", jobId: "would-be-new" });
expect(jobId).toBe("active-job");
// Active bundle is not added to the queue.
expect(getQueuedBundleIds()).toEqual([]);
});
it("isQueuedOrActive is true for both queued and active bundles", () => {
setActive({ bundleId: "ocr", jobId: "active" });
enqueue({ bundleId: "face-detection", jobId: "queued" });
expect(isQueuedOrActive("ocr")).toBe(true);
expect(isQueuedOrActive("face-detection")).toBe(true);
expect(isQueuedOrActive("transcription")).toBe(false);
});
it("peekQueue does not remove; dequeue removes the head", () => {
enqueue({ bundleId: "ocr", jobId: "j1" });
enqueue({ bundleId: "face-detection", jobId: "j2" });
expect(peekQueue()).toEqual({ bundleId: "ocr", jobId: "j1" });
expect(getQueuedBundleIds()).toEqual(["ocr", "face-detection"]);
expect(dequeue()).toEqual({ bundleId: "ocr", jobId: "j1" });
expect(getQueuedBundleIds()).toEqual(["face-detection"]);
});
it("getActiveBundleId reflects setActive / clearActive", () => {
expect(getActiveBundleId()).toBeNull();
setActive({ bundleId: "ocr", jobId: "j1" });
expect(getActiveBundleId()).toBe("ocr");
clearActive();
expect(getActiveBundleId()).toBeNull();
});
it("a bundle can be re-queued after it stops being active", () => {
setActive({ bundleId: "ocr", jobId: "j1" });
clearActive();
const jobId = enqueue({ bundleId: "ocr", jobId: "j2" });
expect(jobId).toBe("j2");
expect(getQueuedBundleIds()).toEqual(["ocr"]);
});
});
@@ -637,6 +637,38 @@ describe("Composite state - getFeatureStates", () => {
expect(ocr?.error).toBe("Install failed: disk full");
});
it("reports a queued bundle as status 'queued'", async () => {
// The queue is a leaf module feature-status imports FROM; enqueue via the
// same (freshly reset) instance so getFeatureStates sees it.
const queue = await import("../../../apps/api/src/lib/feature-install-queue.js");
queue.enqueue({ bundleId: "ocr", jobId: "job-queued" });
try {
const states = mod.getFeatureStates();
const ocr = states.find((s) => s.id === "ocr");
expect(ocr?.status).toBe("queued");
// Bundles not in the queue stay not_installed.
const face = states.find((s) => s.id === "face-detection");
expect(face?.status).toBe("not_installed");
} finally {
queue.resetQueueState();
}
});
it("the currently-installing (lock) bundle takes precedence over queued", async () => {
const queue = await import("../../../apps/api/src/lib/feature-install-queue.js");
// ocr holds the lock (active install); face-detection is queued behind it.
mod.acquireInstallLock("ocr");
queue.enqueue({ bundleId: "face-detection", jobId: "job-2" });
try {
const states = mod.getFeatureStates();
expect(states.find((s) => s.id === "ocr")?.status).toBe("installing");
expect(states.find((s) => s.id === "face-detection")?.status).toBe("queued");
} finally {
queue.resetQueueState();
mod.releaseInstallLock();
}
});
it("each result has correct shape", () => {
mod.markInstalled("ocr", "1.0.0", []);
const states = mod.getFeatureStates();
@@ -653,6 +685,43 @@ describe("Composite state - getFeatureStates", () => {
expect(Array.isArray(state.enablesTools)).toBe(true);
}
});
it("surfaces real per-arch download/on-disk sizes from the manifest", () => {
// Write a manifest carrying archives for both arches; the API should
// surface the entry matching this host's arch.
const arch = process.arch === "arm64" ? "arm64-cpu" : "amd64-gpu";
const other = arch === "arm64-cpu" ? "amd64-gpu" : "arm64-cpu";
writeFileSync(
process.env.FEATURE_MANIFEST_PATH ?? "",
JSON.stringify({
bundles: {
ocr: {
models: [],
archives: {
[arch]: { compressedSize: 5_930_000_000, extractedSize: 9_370_000_000 },
[other]: { compressedSize: 1, extractedSize: 2 },
},
},
// extractedSize omitted / 0 must surface as null, not 0.
"background-removal": {
models: [],
archives: { [arch]: { compressedSize: 4_810_000_000, extractedSize: 0 } },
},
},
}),
);
const states = mod.getFeatureStates();
const ocr = states.find((s) => s.id === "ocr");
expect(ocr?.downloadBytes).toBe(5_930_000_000);
expect(ocr?.installedBytes).toBe(9_370_000_000);
const rembg = states.find((s) => s.id === "background-removal");
expect(rembg?.downloadBytes).toBe(4_810_000_000);
expect(rembg?.installedBytes).toBeNull();
// A bundle with no archives entry surfaces both as null (not undefined/0).
const transcription = states.find((s) => s.id === "transcription");
expect(transcription?.downloadBytes).toBeNull();
expect(transcription?.installedBytes).toBeNull();
});
});
describe("auto-repair state transition (install endpoint logic)", () => {
+10 -10
View File
@@ -146,7 +146,7 @@ describe("Dropzone", () => {
render(<Dropzone />);
expect(screen.getByText("Upload from computer")).toBeDefined();
expect(screen.getByText("Drop your files here")).toBeDefined();
expect(screen.getByText("click anywhere to browse, or paste from clipboard")).toBeDefined();
expect(screen.getByText("use the upload button, or paste from clipboard")).toBeDefined();
});
it("shows supported formats hint", () => {
@@ -186,19 +186,19 @@ describe("Dropzone", () => {
// Click to upload
// ---------------------------------------------------------------------------
describe("click to upload", () => {
it("opens file picker when the section is clicked", () => {
it("keeps the drop zone drag-only when the section is clicked", () => {
const getInput = spyFileInput();
render(<Dropzone />);
fireEvent.click(screen.getByLabelText("File drop zone"));
expect(getInput()).not.toBeNull();
expect(getInput()).toBeNull();
});
it("opens file picker when the Upload button is clicked", () => {
const getInput = spyFileInput();
render(<Dropzone />);
fireEvent.click(screen.getByText("Upload from computer"));
fireEvent.click(screen.getByRole("button", { name: "Upload from computer" }));
expect(getInput()).not.toBeNull();
});
@@ -206,7 +206,7 @@ describe("Dropzone", () => {
const getInput = spyFileInput();
render(<Dropzone />);
fireEvent.click(screen.getByLabelText("File drop zone"));
fireEvent.click(screen.getByRole("button", { name: "Upload from computer" }));
expect(getInput()?.multiple).toBe(true);
});
@@ -214,7 +214,7 @@ describe("Dropzone", () => {
const getInput = spyFileInput();
render(<Dropzone multiple={false} />);
fireEvent.click(screen.getByLabelText("File drop zone"));
fireEvent.click(screen.getByRole("button", { name: "Upload from computer" }));
expect(getInput()?.multiple).toBe(false);
});
@@ -222,7 +222,7 @@ describe("Dropzone", () => {
const getInput = spyFileInput();
render(<Dropzone accept="image/*" />);
fireEvent.click(screen.getByLabelText("File drop zone"));
fireEvent.click(screen.getByRole("button", { name: "Upload from computer" }));
const input = getInput()!;
expect(input.accept).toContain("image/*");
expect(input.accept).toContain(".heic");
@@ -234,7 +234,7 @@ describe("Dropzone", () => {
const getInput = spyFileInput();
render(<Dropzone onFiles={onFiles} />);
fireEvent.click(screen.getByLabelText("File drop zone"));
fireEvent.click(screen.getByRole("button", { name: "Upload from computer" }));
const input = getInput()!;
const file = makeFile("photo.png");
@@ -249,7 +249,7 @@ describe("Dropzone", () => {
const getInput = spyFileInput();
render(<Dropzone onFiles={onFiles} />);
fireEvent.click(screen.getByLabelText("File drop zone"));
fireEvent.click(screen.getByRole("button", { name: "Upload from computer" }));
const input = getInput()!;
const files = [makeFile("a.png"), makeFile("b.jpg", "image/jpeg")];
@@ -264,7 +264,7 @@ describe("Dropzone", () => {
const getInput = spyFileInput();
render(<Dropzone onFiles={onFiles} />);
fireEvent.click(screen.getByLabelText("File drop zone"));
fireEvent.click(screen.getByRole("button", { name: "Upload from computer" }));
const input = getInput()!;
Object.defineProperty(input, "files", { value: [], configurable: true });
+42 -50
View File
@@ -153,70 +153,62 @@ describe("useFeaturesStore (expanded)", () => {
});
});
describe("installBundle queuing edge cases", () => {
it("does not duplicate already-queued bundles", async () => {
// Set up a bundle that is already installing
describe("installBundle queuing (server-owned queue)", () => {
it("adds a server-queued bundle to the queued pill exactly once across duplicate POSTs", async () => {
useFeaturesStore.setState({
installing: { "active-bundle": { percent: 50, stage: "Processing..." } },
bundles: [
makeBundleState({ id: "active-bundle", status: "installing" }),
makeBundleState({ id: "waiting-bundle", status: "not_installed" }),
],
bundles: [makeBundleState({ id: "waiting-bundle", status: "not_installed" })],
});
// Server reports it queued behind an active install both times.
apiPostMock.mockResolvedValue({ jobId: "job-wait", queued: true });
// Start install for waiting-bundle; it should queue
const p1 = useFeaturesStore.getState().installBundle("waiting-bundle");
const p2 = useFeaturesStore.getState().installBundle("waiting-bundle");
await Promise.all([
useFeaturesStore.getState().installBundle("waiting-bundle"),
useFeaturesStore.getState().installBundle("waiting-bundle"),
]);
const q = useFeaturesStore.getState().queued;
expect(q.filter((id) => id === "waiting-bundle").length).toBe(1);
// A queued bundle is not shown as installing.
expect(useFeaturesStore.getState().installing["waiting-bundle"]).toBeUndefined();
});
it("shows a not-queued install as installing (not in the queued pill)", async () => {
useFeaturesStore.setState({
bundles: [makeBundleState({ id: "go-bundle", status: "not_installed" })],
});
apiPostMock.mockResolvedValueOnce({ jobId: "job-go", queued: false });
const promise = useFeaturesStore.getState().installBundle("go-bundle");
await vi.waitFor(() => {
// Should only be queued once
const q = useFeaturesStore.getState().queued;
expect(q.filter((id) => id === "waiting-bundle").length).toBeLessThanOrEqual(1);
expect(useFeaturesStore.getState().installing["go-bundle"]).toBeDefined();
});
expect(useFeaturesStore.getState().queued).not.toContain("go-bundle");
// Clean up: finish the active install so queued ones proceed
useFeaturesStore.setState({ installing: {} });
apiPostMock.mockResolvedValue({ jobId: "job-wait" });
await vi
.waitFor(() => {
if (FakeEventSource.instances.length > 0) return;
throw new Error("waiting");
})
.catch(() => {});
for (const es of FakeEventSource.instances) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
await Promise.allSettled([p1, p2]);
FakeEventSource.instances[0]?.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
});
describe("installBundle skips already-installed bundles from queue", () => {
it("skips bundle that got installed while queued", async () => {
describe("installBundle when the server reports the bundle already installed", () => {
it("still POSTs, then clears state and refreshes on a 409 already-installed", async () => {
useFeaturesStore.setState({
installing: { "first-bundle": { percent: 80, stage: "Finishing" } },
bundles: [
makeBundleState({ id: "first-bundle", status: "installing" }),
makeBundleState({ id: "second-bundle", status: "installed" }),
],
bundles: [makeBundleState({ id: "done-bundle", status: "not_installed" })],
});
apiPostMock.mockRejectedValueOnce(new Error('Bundle "done-bundle" is already installed'));
apiGetMock.mockResolvedValueOnce({
bundles: [makeBundleState({ id: "done-bundle", status: "installed" })],
});
// second-bundle is already installed, so should skip
const promise = useFeaturesStore.getState().installBundle("second-bundle");
await useFeaturesStore.getState().installBundle("done-bundle");
// Clear the active install to unblock
useFeaturesStore.setState({ installing: {} });
await promise;
// apiPost should not have been called for second-bundle since it is installed
const installCalls = apiPostMock.mock.calls.filter(
(call: unknown[]) =>
typeof call[0] === "string" && (call[0] as string).includes("second-bundle"),
);
expect(installCalls.length).toBe(0);
// The client always POSTs now -- the server owns the queue/dedup decision.
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/done-bundle/install", {});
// A 409 already-installed clears installing + error and refreshes silently.
const state = useFeaturesStore.getState();
expect(state.installing["done-bundle"]).toBeUndefined();
expect(state.errors["done-bundle"]).toBeUndefined();
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
});
});
+95 -38
View File
@@ -396,7 +396,9 @@ describe("useFeaturesStore", () => {
});
describe("installAll()", () => {
it("sets installAllActive = true and processes all uninstalled bundles", async () => {
// The server serializes installs now, so installAll just fires one POST per
// not-installed bundle and lets the backend queue them.
it("POSTs an install for every not-installed bundle and sets installAllActive", async () => {
const bundles = [
makeBundleState({ id: "bundle-1", status: "not_installed" }),
makeBundleState({ id: "bundle-2", status: "installed" }),
@@ -404,52 +406,105 @@ describe("useFeaturesStore", () => {
];
useFeaturesStore.setState({ bundles, loaded: true });
apiPostMock.mockImplementation((path: string) => {
if (path.includes("install")) {
return Promise.resolve({ jobId: `job-${path}` });
}
return Promise.resolve({});
});
apiGetMock.mockResolvedValue({
bundles: bundles.map((b) => ({ ...b, status: "installed" })),
});
apiPostMock.mockImplementation((path: string) => Promise.resolve({ jobId: `job-${path}` }));
const promise = useFeaturesStore.getState().installAll();
await useFeaturesStore.getState().installAll();
expect(useFeaturesStore.getState().installAllActive).toBe(true);
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(true);
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/bundle-1/install", {});
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/bundle-3/install", {});
});
// The already-installed bundle is not POSTed.
expect(apiPostMock).not.toHaveBeenCalledWith("/v1/admin/features/bundle-2/install", {});
const completeAllOpen = () => {
for (const es of FakeEventSource.instances) {
if (!es.closed) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
}
};
// Complete both installs; installAllActive clears once installing + queued drain.
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(2);
});
for (const es of FakeEventSource.instances) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBeGreaterThan(0);
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
completeAllOpen();
expect(useFeaturesStore.getState().queued).toEqual([]);
}, 15000);
await vi
.waitFor(
() => {
if (FakeEventSource.instances.length < 2) {
throw new Error("waiting for second EventSource");
}
},
{ timeout: 5000 },
)
.catch(() => {});
completeAllOpen();
it("reflects server-queued bundles in the queued pill during Install All", async () => {
const bundles = [
makeBundleState({ id: "bundle-a", status: "not_installed" }),
makeBundleState({ id: "bundle-b", status: "not_installed" }),
];
useFeaturesStore.setState({ bundles, loaded: true });
await promise;
// Server starts bundle-a immediately and queues bundle-b.
apiPostMock.mockImplementation((path: string) =>
Promise.resolve({
jobId: `job-${path}`,
queued: path.includes("bundle-b"),
}),
);
const state = useFeaturesStore.getState();
expect(state.installAllActive).toBe(false);
expect(state.queued).toEqual([]);
await useFeaturesStore.getState().installAll();
await vi.waitFor(() => {
expect(useFeaturesStore.getState().queued).toContain("bundle-b");
expect(useFeaturesStore.getState().installing["bundle-a"]).toBeDefined();
});
expect(useFeaturesStore.getState().installing["bundle-b"]).toBeUndefined();
// Finish both; the second transitions queued -> installing on its first
// progress frame, then completes.
const esB = FakeEventSource.instances.find((es) => es.url.includes("bundle-b"));
esB?.onmessage?.({ data: JSON.stringify({ phase: "installing", percent: 10, stage: "Go" }) });
await vi.waitFor(() => {
expect(useFeaturesStore.getState().queued).not.toContain("bundle-b");
});
for (const es of FakeEventSource.instances) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
}, 15000);
it("retries a bundle once if it fails during Install All, then stops", async () => {
const bundles = [makeBundleState({ id: "flaky", status: "not_installed" })];
useFeaturesStore.setState({ bundles, loaded: true });
apiPostMock.mockImplementation((path: string) => Promise.resolve({ jobId: `job-${path}` }));
await useFeaturesStore.getState().installAll();
// First attempt fails -> one retry (a second install POST + EventSource).
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
FakeEventSource.instances[0].onmessage?.({
data: JSON.stringify({ phase: "failed", error: "boom" }),
});
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(2);
});
// Still active (retrying), no error surfaced yet.
expect(useFeaturesStore.getState().installAllActive).toBe(true);
expect(useFeaturesStore.getState().errors.flaky).toBeUndefined();
// Second attempt fails too -> no further retry; error recorded, run ends.
FakeEventSource.instances[1].onmessage?.({
data: JSON.stringify({ phase: "failed", error: "boom again" }),
});
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
expect(FakeEventSource.instances.length).toBe(2);
expect(useFeaturesStore.getState().errors.flaky).toBe("boom again");
}, 15000);
it("clears stale errors for pending bundles", async () => {
@@ -465,7 +520,7 @@ describe("useFeaturesStore", () => {
bundles: bundles.map((b) => ({ ...b, status: "installed" })),
});
const promise = useFeaturesStore.getState().installAll();
await useFeaturesStore.getState().installAll();
await vi.waitFor(() => {
expect(useFeaturesStore.getState().errors["err-bundle"]).toBeUndefined();
@@ -479,7 +534,9 @@ describe("useFeaturesStore", () => {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
await promise;
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
}, 15000);
});
+17 -13
View File
@@ -530,24 +530,28 @@ describe("getToolRegistryEntry", () => {
// Wrapper components (lines 305-324)
// ==========================================================================
import { render } from "@testing-library/react";
import { act, render } from "@testing-library/react";
import React from "react";
function renderSettings(Settings: React.ComponentType<Record<string, unknown>>, props = {}) {
return render(
async function renderSettings(Settings: React.ComponentType<Record<string, unknown>>, props = {}) {
const result = render(
React.createElement(React.Suspense, { fallback: null }, React.createElement(Settings, props)),
);
await act(async () => {
await vi.dynamicImportSettled();
});
return result;
}
describe("CropSettingsWrapper", () => {
it("renders null when cropProps is undefined", () => {
it("renders null when cropProps is undefined", async () => {
const entry = getToolRegistryEntry("crop");
expect(entry).toBeDefined();
const { container } = renderSettings(entry?.Settings as never);
const { container } = await renderSettings(entry?.Settings as never);
expect(container.innerHTML).toBe("");
});
it("renders CropSettings when cropProps is provided", () => {
it("renders CropSettings when cropProps is provided", async () => {
const entry = getToolRegistryEntry("crop");
expect(entry).toBeDefined();
const cropProps = {
@@ -561,20 +565,20 @@ describe("CropSettingsWrapper", () => {
onAspectChange: vi.fn(),
onGridToggle: vi.fn(),
};
const { container } = renderSettings(entry?.Settings as never, { cropProps });
const { container } = await renderSettings(entry?.Settings as never, { cropProps });
expect(container).toBeDefined();
});
});
describe("EraseObjectSettingsWrapper", () => {
it("renders null when eraserProps is undefined", () => {
it("renders null when eraserProps is undefined", async () => {
const entry = getToolRegistryEntry("erase-object");
expect(entry).toBeDefined();
const { container } = renderSettings(entry?.Settings as never);
const { container } = await renderSettings(entry?.Settings as never);
expect(container.innerHTML).toBe("");
});
it("renders EraseObjectSettings when eraserProps is provided", () => {
it("renders EraseObjectSettings when eraserProps is provided", async () => {
const entry = getToolRegistryEntry("erase-object");
expect(entry).toBeDefined();
const eraserProps = {
@@ -583,16 +587,16 @@ describe("EraseObjectSettingsWrapper", () => {
brushSize: 20,
onBrushSizeChange: vi.fn(),
};
const { container } = renderSettings(entry?.Settings as never, { eraserProps });
const { container } = await renderSettings(entry?.Settings as never, { eraserProps });
expect(container).toBeDefined();
});
});
describe("makeColorSettingsComponent", () => {
it("adjust-colors Settings renders without throwing", () => {
it("adjust-colors Settings renders without throwing", async () => {
const entry = getToolRegistryEntry("adjust-colors");
expect(entry).toBeDefined();
const { container } = renderSettings(entry?.Settings as never, {
const { container } = await renderSettings(entry?.Settings as never, {
onPreviewFilter: vi.fn(),
});
expect(container).toBeDefined();
+9 -4
View File
@@ -2552,7 +2552,7 @@ describe("useFeaturesStore", () => {
// -- installAll -----------------------------------------------------------
it("installAll processes all not-installed bundles sequentially", async () => {
it("installAll POSTs an install for each not-installed bundle", async () => {
const bundles = [
{
id: "ai-rembg",
@@ -2610,10 +2610,15 @@ describe("useFeaturesStore", () => {
await useFeaturesStore.getState().installAll();
expect(useFeaturesStore.getState().installAllActive).toBe(false);
expect(useFeaturesStore.getState().queued).toEqual([]);
// Only not-installed bundle should have been installed
// The install POST fires immediately (the server serializes installs now);
// only the not-installed bundle is POSTed.
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/install", {});
expect(mockApiPost).not.toHaveBeenCalledWith("/v1/admin/features/ai-esrgan/install", {});
// installAllActive clears once the (mock) EventSource reports completion.
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
expect(useFeaturesStore.getState().queued).toEqual([]);
});
it("installAll skips bundles that are already installed", async () => {