Bumps every version surface to 2.2.0, fixes a latent version-coupling bug in the
OCR runtime tests, and stops an absent GPU runner from silently stalling a
release.
Version surfaces: scripts/sync-version.sh covers the 11 workspaces, APP_VERSION,
and the docs release commands across all locales. Root package.json plus the
three surfaces the script never reaches are done by hand: the DOCKERHUB.md banner
and tag table, the docker-tags.md pinning table in 21 locales, and the example
runtimeVersion in tools/image/ocr.md in 21 locales. The release-notes archive step
is deliberately not pre-run, so the notes text stays editable until the release.
Latent bug: runtime-state rejects any runtime whose compatibility.snapotterVersion
is not exactly APP_VERSION, and five fixtures pinned the literal 2.1.0. Since
semantic-release rewrites APP_VERSION on every release, the first PR after any
bump would have gone red for a reason nobody would trace to the release. The
fixtures now derive from APP_VERSION.
GPU runner: sign-ocr-index needs verify-ocr-nvidia on self-hosted hardware, and
the gated manifest job needs ai-bundles, so a missing runner queued instead of
failing and produced no image tags. preflight-gpu-runner claims the same labels
with no dependencies, so it is scheduled first and validates the GPU before the
90-minute build. An API preflight is impossible because listing self-hosted
runners needs Administration:read, which GITHUB_TOKEN cannot hold, so RELEASE.md
carries the maintainer-side check.
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.
## Fixes that change behaviour
Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.
A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.
A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.
Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.
Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.
RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.
## Gates that could not fail
Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.
Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
AI sidecar failures reached Sentry as 'Error: Error': the scrubber type-onlys plain Errors and the tool wrappers threw them from result.error. The bridge now exports toSidecarError(), wrapping the sidecar reason in a SafeError (memory-allocation text classifies as operational, the rest as bug); all 14 wrappers use it, plus the dispatcher crash/stdin/spawn rejection paths and parseStdoutJson. toBgRemovalError from #535 delegates to the shared helper.
On the web side, DOMExceptions report their specific name via err.name, so the NATIVE_ERRORS allowlist dropped the whole family's browser-authored messages. It now carries the full WebIDL DOMException name table; messages still pass through url/path redaction.
Bridge-mocking test files switched to importOriginal passthrough mocks.
RealESRGAN's enhance() and rembg's remove() run in one opaque call, so the
progress bar froze at 30% for the whole inference. Add a time-based
heartbeat that advances the bar in a background thread while the model runs
and stops when it returns, so the bar moves instead of freezing. Verified
end to end on a GPU box (forced CPU): a 55s upscale emitted 26 steady ticks
then completed; background removal too.
Fixes#591
Adds an opt-in High Quality mode to the Object Eraser, backed by a new inpaint-hq feature bundle (Stable Diffusion 1.5 inpainting via diffusers). The default fast LaMa path is unchanged. Both arch archives are published to deepsafe/feature-bundles and the manifest carries their real sha256/sizes.
Verified end to end: a fresh container pulls the bundle from HuggingFace, checksum-verifies it, extracts torch/diffusers plus the fp16 model, and the HQ sidecar erases a large object with a plausible fill.
Refs #141
removeBackground failures wrap in a SafeError so the specific reason survives the Sentry scrubber; the OOM lighter-model fallback and bridge SafeError passthrough are preserved.
Route both Python exit paths through pythonExitError so the reason survives the scrubber; OOM/segfault stay operational and keep "out of memory" for the lighter-model fallback.
Follow-ups to the v2.1.0 Sentry telemetry overhaul, found by reviewing live release:2.1.0 events:
- error_code tag was empty because reportError read only the top-level err.code; add extractErrorCode() to walk the cause chain (pg SQLSTATE, node E-code, else first short code).
- InputValidationError from a tool's processV2 in the worker was logged as error_class=bug; classify it as expected for any source. Worker-side ZodError stays a bug (schema drift).
- AI dispatcher timeouts rejected with a bare Error, which the sanitizer scrubbed to a message-less "Error: Error"; reject with an operational SafeError (code "timeout") at both timeout sites.
Each fix written failing-test-first; affected and adjacent unit suites green plus full CI (integration + e2e).
Removes Sentry tracing entirely (BullMQ idle polling burned 4.8M transactions in 2 days at the baked 0.1 rate), decouples PostHog sampling, and replaces the type-only error scrub with a vetted-field sanitizer plus SafeError/ToolInputError contracts. One classified capture path with per-signature throttles and a per-process ceiling makes storms impossible (NODE-1E was 4,541 events from one 30s loop). Browser errors move to a dedicated web Sentry project with their own source maps. Adds the SNAPOTTER_TELEMETRY runtime kill switch and silences test fleets.
Crash fixes: remote 204/304 SSRF process kill (NODE-20), conversion-preset boot crash loop (NODE-21), Redis version preflight + unhandled subscribe rejection (NODE-1T), Sign PDF on plain-http origins (NODE-1K/1M), wavesurfer/pdf.js teardown rejections (NODE-1P/1N), bundle-import ZlibError to 400 (NODE-1Z), chart-maker input errors declassified (NODE-1H/1J), asset requests skip the session DB lookup (NODE-1D).
Make on-demand AI feature-bundle installs reliable and self-healing, closing
the failure modes behind most "some tool doesn't work" reports.
Multi-bundle installs: tools needing more than one bundle (Passport Photo,
Enhance Faces) install every required bundle from one action and stay
not-installed until all are present. Verified across all 19 AI tools.
Downloads: self-heal the accelerated Hugging Face (Xet) client so an upgraded
venv no longer silently falls back to slow urllib; restart instead of
corrupting a resumed partial when a proxy ignores Range and returns 200;
verify the completed size; fail fast on disk-full and HTTP 4xx; retry
transient errors five times; add hf_transfer fallback and document Xet egress.
Install integrity: crash-atomic venv writes so a killed or out-of-space
install can no longer tear the shared venv and break other tools; a boot
breadcrumb reseeds a torn venv to a clean state automatically; a post-install
smoke import test refuses to record a bundle whose libraries cannot load; an
install watchdog stops a wedged installer that would otherwise hold the venv
writer lock forever.
Adds unit and end-to-end tests for every failure mode above.
Fixes 15 defects found by a max-effort multi-agent review of the last 6
merged PRs (#388, #390, #391, #392, #393, #394), all adversarially
verified before fixing.
Install queue + dispatcher (the serious cluster):
- features.ts: finalize the installer child exactly once. A failed spawn
fires both "error" and "close", and the second event released the file
lock and active slot that pump() had just handed to the next queued
bundle, letting two pip processes write the same venv concurrently.
Outcome recording now happens before pump() so the next bundle's first
progress frame cannot race the previous install's bookkeeping.
- feature-status.ts: keep failed-install errors in a per-bundle map
instead of the single progress slot. With the queue auto-starting the
next install, the slot was overwritten within seconds and a failed
install vanished without ever surfacing to GET /features.
- bridge.ts: scope child lifecycle per process (stopped-children set +
request generation tags) instead of an instance-wide shuttingDown flag
that the next spawn reset. A stale SIGTERMed child's late close event
could record a phantom crash (5 of which permanently disable the
dispatcher), null out the freshly spawned child, and reject the new
child's pending requests. The request-timeout kill path still counts
as a real crash.
- install_feature.py: the pre-write disk re-check measured ai_dir's
filesystem even when budgeting the cross-filesystem copy that lands on
the venv's disk; now each budget is checked against the filesystem the
bytes actually land on, so ENOSPC cannot strike mid-write and leave
site-packages half overwritten.
Behavior regressions:
- embed-subtitles: preserve pre-existing subtitle tracks (0:s?) and MKV
attachments (0:t?) that the -map 0:v:0/0:a? rewrite silently dropped;
data streams stay unmapped on purpose (the actual MPEG remux fix). The
new subtitle maps first so the language tag hits the right stream.
- usage-survey-overlay: fail closed when the settings fetch fails; the
fail-open path rendered the blocking survey against an unhealthy API
and soft-locked admins, the lock-out class #392 fixed.
- features-store: queued bundles poll instead of each holding an SSE
connection (Install All could pin 7 EventSources and exhaust the
browser's 6-per-origin HTTP/1.1 limit, hanging the whole app);
listenToProgress closes any prior stream and stops any poll before
subscribing; installAll skips bundles already installing or queued.
Contracts, tests, i18n:
- openapi.yaml: add "queued" to the features status enum and document
downloadBytes/installedBytes (Schemathesis conformance).
- feature-lifecycle e2e: queue transcription (~0.5 GB) instead of ocr
(~6 GB) and give the test a budget that covers both install drains
(the stacked waits exceeded the old 900s timeout).
- docker-compose.qa.yml: parameterize the host port (QA_APP_PORT) so
QA_PROJECT_NAME concurrent stacks can actually bind.
- compare + watermark-image: restore per-input error attribution
("Invalid first/second image", "Invalid watermark image") lost in the
shared-handler migration.
- ai-features-section: the "{size} on disk" suffix now goes through
i18n; key added to all 21 locales.
- watermark-image + content-aware-resize: migrate to the shared
inputHandlerFor("image") chain like compare/vectorize/compose, fixing
drift in the inline copies (no SVG sanitize, no RAW extension hint,
no AVIF probe).
Verified: typecheck across 9 workspaces, Biome clean on all changed
files, 584 targeted unit tests and 249 integration tests green
(including real-ffmpeg embed-subtitles runs). One unit test updated to
the new poll-while-queued contract with a single-EventSource assertion.
Claude-Session: https://claude.ai/code/session_017mR1HiHaf3a1BmUtrHX4j3
Found and fixed during a full local Docker build validation (amd64/arm64, all
four fleet targets, AI bundle installs, QA harness) and the follow-up bug
sweep requested afterward. None of the affected scripts run in CI, so these
had been silently broken indefinitely.
- docker/feature-manifest.json: pythonVersion was a flat "3.11", but the
amd64 base (Ubuntu 24.04) ships Python 3.12 while arm64 (Debian bookworm)
ships 3.11. Changed to a per-arch object matching the file's existing
convention.
- tests/qa/api-sweep.mts and verify-ai.mts: bare "@snapotter/shared" import
can't resolve since tests/ is not a pnpm workspace member, making both
silently unrunnable via their own documented command on any fresh
checkout. Switched to a relative import.
- tests/qa/generate-ledger.mts: wrote to docs/qa/ without creating the
directory first; docs/ is gitignored except COMMUNITY_GUIDE.md, so a fresh
checkout threw ENOENT.
- Seven QA Playwright spec files (input-preview, settings,
settings-extended, multifile, output-preview, pipeline-ui, smoke) had
~115 fixture() calls using directory names that don't exist. Resolved
every call programmatically against the real fixture tree.
- packages/ai/src/bridge.ts: AI dispatcher restart (happens on every bundle
install) was falsely counted as a crash, risking permanent dispatcher
disable after enough legitimate restarts within the crash window. Added a
shuttingDown flag checked at all three recordCrash() call sites.
- packages/image-engine/src/operations/auto-enhance.ts: image-enhancement
hung 40+ seconds on large RAW photos (confirmed on a real 20.2MP file) in
Sharp's .clahe() step, whose cost scales with total pixel count regardless
of tile size. Added a 16-megapixel cap above which CLAHE is skipped;
verified against the real file (40+s -> 2.0s) with no regression to other
RAW formats or normal-sized images. Fixing this surfaced a second,
smaller bug where the saturation step's CLAHE compensation boost was
keyed off the raw toggle instead of whether CLAHE actually ran.
- Two QA-harness robustness gaps closed per "fix everything, even the small
bugs": the passport-photo/erase-object input-preview tests now skip
cleanly with a clear reason on a container without their AI bundle
installed, and docker-compose.qa.yml's hardcoded project/container name
(the actual root cause of a mid-validation container swap between two
concurrent sessions) is now parameterized via QA_PROJECT_NAME.
Full validation report is local-only per repo convention.
The persistent Python dispatcher rejects scripts whose feature bundle is not
installed, but the per-request fallback (used when the dispatcher is down, e.g.
restarting right after a model repair) spawned scripts directly and bypassed
that gate. Behavior was therefore inconsistent: a gated script would fail under
the dispatcher but run under the fallback -- the "works once after a repair"
symptom from the original report.
- add packages/ai/src/feature-gate.ts: SCRIPT_BUNDLE_MAP + missingBundleForScript,
mirroring TOOL_BUNDLE_MAP in dispatcher.py, reading the same installed.json and
failing closed exactly like dispatcher._get_installed_bundles()
- runPerRequest now rejects with "feature_not_installed" (the same message the
dispatcher path surfaces) when a gated script's bundle is not installed
- unit tests for the gate, plus a drift test pinning the TS map to dispatcher.py
Closes#327
- Expose birefnet-hr-matting in UI (People/Ultra) and fix model defaults
(People/Max now uses birefnet-matting for true alpha matting)
- Add output format selector (PNG/WebP/AVIF) with lossless alpha support
- Add edge smoothing post-processing (Off/Light/Medium/Strong) via
morphological mask refinement to reduce gray halo artifacts
- Add color decontamination to remove background color spill from
semi-transparent edge pixels
- Thread new settings through full stack: frontend -> API schema ->
Python sidecar -> Sharp effects pipeline
- Add i18n keys for all 21 locales
- Add unit tests for new option serialization (3 tests)
- Add integration tests for new settings validation (4 tests)
The GPU detection in gpu.py had two issues preventing GPU usage in
containers (especially rootless podman with CDI):
1. When torch was installed but torch.cuda.is_available() returned
False, the function returned immediately without trying the
ONNX Runtime + nvidia-smi fallback. This meant a CPU-only torch
build (installed before GPU was available) would block all GPU
detection, even for ONNX-based tools.
2. The failure logged a generic "torch loaded but CUDA not available"
with no diagnostic information, making it impossible to debug
whether the issue was a CPU-only build, missing libraries, or
device permissions.
The fix restructures gpu_available() into three detection tiers
(torch -> ONNX Runtime -> nvidia-smi) that always fall through on
failure. When torch CUDA fails, it now checks torch.version.cuda to
distinguish CPU-only builds from CUDA builds that can't access the
GPU, and logs LD_LIBRARY_PATH, torch.cuda.init() errors, and
nvidia-smi results.
Also fixes two env var passthrough bugs in buildMinimalEnv():
- SNAPOTTER_GPU was never passed to the Python subprocess, so the
user-facing GPU override env var had no effect
- MODELS_DIR was a dead entry (never set as env var); replaced with
MODELS_PATH which the Dockerfile sets and Python scripts read
Closes#134
AVIF (and other Sharp-native formats) were written as raw bytes to a
.png temp file, causing PIL to fail with "cannot identify image file".
Every other AI module wrapper already converts via sharp().png().toBuffer()
before writing; face-landmarks was the only one that skipped this step.
- Refactor use-tool-processor and use-pipeline-processor hooks
- Enhance dropzone component with improved UX
- Improve seam carving with better error handling and tests
- Add JXL format encoding support to format-encoders
- Update tool routes for consistent format handling
- Add dropzone unit tests
The upscale function called runPythonWithProgress without a timeout parameter,
defaulting to the bridge's 10-minute hard limit. On CPU-only systems like
Synology NAS devices, Real-ESRGAN 4x upscaling easily exceeds this for modest
images. Additionally, when the timeout fired on the dispatcher path, the Python
process was left running and blocked all subsequent AI operations.
This fix adds an adaptive timeout based on input megapixels, scale factor, and
GPU availability (180s/effective-MP on CPU, 30s/effective-MP on GPU, floor of
10 minutes). It also kills the dispatcher on timeout so subsequent requests can
proceed via a fresh restart.
Closes#119
The dispatcher was lazy-initialized on first AI request, but a race
condition meant the first call always missed it (dispatcherReady still
false) and fell through to cold per-request Python. initDispatcher()
starts the dispatcher eagerly and returns a Promise that resolves with
GPU status once ready (or after a timeout).
The close handler called recordCrash() unconditionally, even for exit
code 0 (normal MAX_REQUESTS restart). After 5 normal cycles within 60s
the dispatcher was permanently disabled. Now only non-zero exits count.
Add ~500 new E2E tests and ~300 new integration tests covering:
- 24 new GUI E2E specs: navigation, responsive layout, keyboard shortcuts,
tool UI for all 35 non-AI tools, batch/pipeline workflows, settings/RBAC,
visual regression, accessibility, and performance budgets
- 3 new E2E-Docker specs: batch workflows, advanced pipelines, cross-format
- 1 new adversarial integration test: memory pressure, corrupted files,
unicode filenames, extreme dimensions, pipeline/batch edge cases
- 29 expanded integration test files: HEIC/HEIF input, large files, parameter
boundaries, batch processing, format edge cases across all tools
- Cross-format matrix expanded: 641 tests covering every tool x 18 formats
- AI bridge unit tests expanded: lifecycle, tool modules, error propagation
- Unit test gaps filled: analytics, tool-registry, web stores
Also fixes:
- vitest.config.ts: exclude e2e-docs and e2e-landing from Vitest runner
- AI E2E specs: add sidecar health check to skip gracefully when Python
AI backend is not running instead of timing out
- Add 55 unit tests for feature-status.ts (installed.json CRUD, cache
behavior, install lock, model verification, crash recovery, composite
state) using real temp directories
- Add 36 integration tests for full install/uninstall lifecycle against
Docker containers (face-detection bundle, SSE progress, tool gates,
shared model protection, concurrent install prevention, auth guards,
container restart recovery)
- Fix noise-removal CPU timeout by adding megapixel-based timeout
calculation (120s/MP, min 5 minutes)
- Fix Playwright auth storage state race condition (mkdirSync before
saving analytics-user.json)
- Fix 2 skipped tests in fixes-verification.spec.ts by replacing
external ~/Downloads/sample dependency with existing test fixtures
- Enable skipped analytics-consent settings toggle test
- Restructure features.spec.ts to manage bundle state (uninstall/
reinstall OCR) so 501 guard tests run instead of skipping
- Update noise-removal test mock to include sharp metadata() method
Add 12 tests for background-removal downscaling (resize gate, portrait
orientation, mask upscale) and OOM model fallback (retry with u2net,
progress callback, no-retry guards, cascading failure).
Add OOM propagation tests to face-detection, noise-removal,
red-eye-removal, and OCR -- the four AI features that were missing them.
Skip alpha matting on CPU (pymatting's sparse matrices are the main
memory hog), auto-downscale images above 2048px before sending to
rembg, and retry with the lighter u2net model when OOM is detected.
Code fixes:
- Sidebar state bleed: reset file store on HomePage mount
- restore-photo: raise error instead of silently skipping colorize
when DDColor model missing
- PaddleOCR OOM: cap input images to 2048px before OCR inference
- Torch CPU optimization: use --index-url .../whl/cpu on CPU nodes
Test fixes:
- upscale: add exact:true to scale factor button locators
- smart-crop: add exact:true to "Pad to square" locator
- colorize: use regex for model button names (Best/Balanced/Fast)
- enhance-faces: use .first() for ambiguous percentage display
- passport-photo: fix DPI locator, .or() compound, generate fallback
- people: update maxUsers assertions for unlimited (0) default
- automate: "Save Pipeline" → "Save" matching actual button text
- tools.test: add resize to Sharp mock chain for OCR tests
- Unit: 1,353 tests (42 files) — +256 new tests covering AI bridge
modules, image-engine sharpen/optimize-for-web, Zustand stores, and
icon-map validation
- Integration: 1,640 tests (57 files) — +826 new tests across all
tool routes, pipeline/progress/batch infrastructure, user-files,
edit-metadata, and a 321-test cross-format matrix
- E2E-Docker: 389 passing (20 spec files) — 6 new spec files for
batch processing, format conversion, layout, optimization,
watermark/overlay, and pipeline chains. Tests verified against fresh
Docker container with all 6 AI bundles installed.
Bug fixes discovered during testing:
- fix(compress): SVG/BMP/exotic formats crashed Sharp encoder — added
format-safety fallback to PNG
- fix(rate-limit): increase default login attempt limit from 10 to 500
per minute — previous value caused false test failures and is too
restrictive for a self-hosted app
- fix(auth.setup): wait for consent button visibility before clicking
to prevent flaky E2E-Docker auth setup