Add friendlyError() which collapses raw external-tool failure output (ffmpeg/ffprobe/LibreOffice/qpdf/etc.) into one generic sentence while preserving intentional validation messages and scrubbing internal paths. Apply it at every client-facing error surface in the tool factory and job worker (sync 422, async SSE, pipeline + batch finalize). The full error is still recorded server-side via request.log.error / logger.error and telemetry.
GIFs decode to bgra/gbrap (alpha); libvpx-vp9 rejects those pixel formats so the encoder never opened (ffmpeg exit 234), breaking GIF->WebM for essentially every GIF. Flatten to yuv420p in the webm branch, matching what the mp4 branch already does.
While getting the editor e2e suite green, three "stale test" failures turned
out to be real bugs (per the reporter's hunch that tests might be catching
real issues):
- Layer effects (drop shadow, glows) never applied. The panel wrote effects
into `attrs.effects` through updateObject, but the panel and renderer both
read the object's top-level `effects`, so the toggle never persisted. Add a
dedicated `setObjectEffects` store action and route the panel through it.
- Object flip (transform tool) did nothing. No object renderer applied
`scaleX`/`scaleY`, and the flip negated scale without compensating position.
Apply scale in the renderers and flip in place: mirror points for stroke
objects, negate scale + shift position for sized objects.
(The paint-bucket / pixel-tool coordinate bug and the broken-at-non-100%-zoom
export were fixed in the preceding #259 change.)
Also adds a small "Beta" badge to the editor (welcome heading + nav link) and
repairs ~18 stale editor e2e specs whose selectors/assertions had drifted from
the current UI: the options bar is `h-9` not `h-10` (added a stable
`data-testid`), the menu bar is `h-8`/`bg-background`, the flip button
aria-labels are lowercase, the welcome "Image Editor" heading collides with an
sr-only `<h1>`, the color-picker tabs need a role-scoped selector, and the
magic-wand / flip tests now use deterministic setup and assert the actual
effect instead of fragile screenshot diffs.
Every editor tool that reads or writes raster pixels exported the stage with `stage.toCanvas({ x: 0, y: 0, width, height })`, which bakes in the stage's zoom/pan transform. The captured buffer was the *viewport* (the document scaled and offset by the current zoom/pan), not the document in its own coordinate space, so tools sampled and wrote the wrong pixels: the paint bucket produced a misplaced black rectangle instead of flood-filling the click, the eyedropper read the wrong colour, the magic wand selected the wrong region, and PNG/clipboard export silently produced a scaled/offset image at any zoom other than 100%.
Add `captureDocumentCanvas()`, which normalizes the stage to the document size with an identity transform, renders, captures, and restores -- all synchronously, so there is no visible flicker. Route every pixel capture through it: fill, magic wand, clone stamp, eyedropper, dodge/burn, blur/sharpen/smudge, the adjustments histogram, and the exporter.
The 'rulers render as black bars' part of #259 was fixed in the preceding editor-layout change (#258).
Adds editor-tool-coordinates.spec.ts asserting the paint bucket fills at the clicked location.
The image editor canvas only used part of the viewport, and the right sidebar was a fixed width that could clip its controls on shorter screens.
- Canvas: the canvas container used `flex-1`, but its parent wrapper in editor-page.tsx was not a flex container, so it collapsed to the Konva Stage's content height (~600px), leaving a large inert region below. Make the wrapper a flex container so the canvas fills the available area.
- Rulers: ruler background/ticks were set via `ctx.fillStyle = "var(--color-card)"`, which canvas 2D cannot parse, so the default black fill remained and painted the rulers as solid black bars. Resolve the theme tokens to concrete colors from computed style at draw time (theme-aware).
- Right panel: add a left-edge drag handle to resize the panel (240-480px, persisted to localStorage) and `min-h-0` so the tab content scrolls internally instead of pushing the color controls off-screen.
Adds editor-layout.spec.ts (canvas-fill + resize) and a ruler-not-black regression test. All 7 targeted editor e2e tests pass.
Overhaul the bug report form and restructure issue intake for 2.0:
- Bug report: split actual vs expected behavior, add Affected area and
Specific tool fields for triage, replace the self-defeating required
"latest version" checkbox with a Version field that accepts a tag,
release, or commit (always answerable from any install), modernize
Docker guidance to the Compose stack, add Host OS, and fix the
previously required image-tag field that source users could not fill.
- Move feature requests to GitHub Discussions: delete the feature_request
issue form, rename the orphaned discussion form to ideas.yml so it binds
to the built-in Ideas category, and point config.yml there.
- Add Translation and Documentation issue forms routed to existing labels.
- Remove the roadmap-update discussion form (roadmap is private).
- Reconcile CONTRIBUTING.md, SUPPORT.md, and the published docs
contributing guide with the new routing.
* fix(docs): keep gray-matter on js-yaml 3 so the docs site builds
The js-yaml >=4.2.0 override from #257 forced js-yaml 4 onto gray-matter (used by vitepress and vitepress-plugin-llms), which calls the removed yaml.safeLoad and broke `vitepress build`. Scope a gray-matter>js-yaml ^3.14.1 override so gray-matter keeps the v3 API (build-time, trusted frontmatter only) while app code stays on js-yaml 4.2.0+.
* docs: add per-tool reference pages for all 157 tools, with a modality sidebar
Generate /tools/<id> pages for the 104 tools that lacked one (video 29, audio 17, document 36, data 10, and 12 newer image tools), matching the existing page format (API endpoint, parameters from the OpenAPI spec, curl example, response, notes). Async/AI tools document the 202+SSE flow and feature-bundle requirement.
Sidebar: add Video / Audio / PDF & Documents / Data groups with per-tool links, fold the 12 new image tools into the existing image categories, and replace the placeholder rest.md-anchor group. Docs site builds cleanly (157 pages, no dead links).
Bump astro ^5.8.0 -> ^6.4.7, @astrojs/sitemap ^3.3.0 -> ^3.7.3, @astrojs/check ^0.9.0 -> ^0.9.9.
The landing uses none of the Astro APIs removed in v6 (no Astro.glob, ViewTransitions, or content collections), so no code changes were required. Astro 6 runs on the repo's existing Vite 8 (the monorepo is already on Vite 8 via the shared override).
Verified: landing build (165 pages) + astro check (0 errors); full monorepo build/typecheck/lint all pass; homepage renders correctly.
rest.md and the docs sidebar still listed only image tools. The OpenAPI spec (#254) now covers all 157 tools, but the hand-written reference lagged.
- rest.md: add Video (29), Audio (17), Document (36), and Data (10) tool tables, and add the 12 newer image tools into their existing category tables (168 tool rows total)
- sidebar: add a "Video, Audio, Document & Data" group linking to the new rest.md sections, so the nav reflects all modalities without needing per-tool pages
Docs site builds cleanly (no dead links). Per-tool doc pages for non-image tools are intentionally not generated here; the REST reference + Scalar + /llms.txt cover them.
* docs: rebrand from image-only to multi-modality across docs and metadata
SnapOtter expanded from image-only to 157 tools across 5 modalities
(image, video, audio, document/PDF, data). Update all product-level
copy, metadata, and i18n that still framed it as an image-only tool.
- README, package.json, root llms.txt: multi-modality framing, 157 tools
- OpenAPI info + tags, generated /llms.txt tagline (docs.ts)
- VitePress docs site: hero, getting-started, architecture, security,
deployment, configuration, developer, supported-formats
- i18n: 10 product keys across all 21 locales (hero, app description,
privacy notes, AI features, progress messages, getting-started)
- web/demo/landing meta + privacy copy, COMMUNITY_GUIDE, .env.example
Stale tool counts (53/50+/52/70+/35) corrected to 157 throughout.
Database/container deployment claims left unchanged (out of scope).
* docs: fix stale post-rebrand test assertions and README language list
- tests/e2e-docs/homepage.spec.ts: assert the current docs homepage (file toolkit, 157 tools, 5 modalities) instead of the old image-only strings
- tests/unit/api/docs-route.test.ts: sync the reproduced llms.txt tagline with docs.ts
- README.md: 21 languages with the correct list (add Swedish and Chinese Traditional, drop Czech which is not supported)
* docs: correct 2.0 architecture references (Postgres 17 + Redis 8, 3-container stack)
The docs and metadata still described the 1.x stack (SQLite, single container, p-queue). Update them to the current 2.0 reality.
- README: replace the broken single-container `docker run` quick-start with the real Docker Compose stack (app + Postgres 17 + Redis 8); fix the "no Redis, no Postgres" feature bullet
- package.json: description no longer claims a single container
- apps/docs: rewrite database.md for Postgres; configuration.md DB_PATH -> DATABASE_URL + REDIS_URL; architecture.md SQLite/p-queue/better-sqlite3 -> Postgres/BullMQ/pg and add media-engine + doc-engine; developer/security/deployment/docker-tags/getting-started/contributing compose examples now include postgres + redis; index.md + api/ai.md AI count 16 -> 19
- SECURITY.md: Drizzle (SQLite) -> (PostgreSQL)
- landing: enterprise/FeatureHighlights single-container wording; TrustSignals/ToolGrid 150+ -> 157 (dynamic); Pricing/FAQ 15 -> 19 AI tools
* docs(api): document all video, audio, document, and data tool endpoints in OpenAPI
The spec covered only image tools; the Scalar UI and the generated /llms.txt and /llms-full.txt inherited that gap. Add the 104 missing tool endpoints so the API docs match the code.
- Video: 29 endpoints (most long/async; auto-subtitles is AI)
- Audio: 17 (transcribe-audio is AI)
- Document/PDF: 36 (ocr-pdf is AI; conversions are long/async)
- Data: 10
- Image: 12 newer tools (background-replace, blur-background AI; histogram/lqip-placeholder/sprite-sheet custom responses; barcode-generate uses a JSON body)
Each schema is derived from the tool's Zod validator and executionHint (fast -> 200, long -> 202+SSE, AI adds 501 FeatureNotInstalledError, multi-file inputs as arrays), referencing the existing shared schemas. Tool path entries: 64 -> 168. Spec parses as valid YAML with no duplicate paths and only known $refs.
SnapOtter spans five modalities now, but several code paths still assumed image input.
- dropzone: default to accept-all when no fileFilter is given (image tools still pass one); neutral "supported file types" error text instead of "image files"
- automate (pipelines): accept any modality in the file pickers and dropzones; render modality-aware previews (video player, audio waveform, document/data card) instead of always using ImageViewer/BeforeAfterSlider
- filename sanitizer: extend the double-extension allowlist beyond image extensions to video/audio/document/data so e.g. "report.csv.php" becomes "report.csv"; add tests
- thumbnail route: return 422 for non-rasterisable files (audio, data, non-PDF docs) instead of attempting a doomed Sharp decode
- pool: unknown tools fall back to the "system" pool, not the image pool
- a11y labels: "Previous/Next image", "Image viewer/area/controls/drop zone" are now modality-neutral, across all 21 locales
- copy: bulk-rename default, find-duplicates ZIP name, SSRF user-agent, fetch-urls fallback name, file-details MIME label, URL-import placeholder, help dialog
Collapses the error_message line introduced in #251 plus the pre-existing
preValidate blocks and the json-xml wrapped assignment that were already
failing apps/api lint on main. Pure formatting, no logic change.
* fix(pdf): never enlarge on compress, honor redact case, hide same-format convert
- compress-pdf: guard both modes so output is never larger than the input; low-DPI scans could be upsampled and grow. Falls back to the original bytes.
- doc_redact.py: caseSensitive=true now filters PyMuPDF's case-insensitive search to exact-case hits, so the toggle works instead of always over-redacting.
- convert-{document,presentation,spreadsheet}: omit the input's own format from the output dropdown; the backend already rejects same-format conversions.
Verified end-to-end against an isolated Docker stack during a full visual QA sweep of all 37 PDF tools.
* fix(ui): show real multi-file preview thumbnails per modality
The bottom multi-file preview strip rendered a raw <img src=blobUrl> for every file, so audio/video/PDF inputs showed a broken-image icon plus the filename. ThumbnailStrip now branches on FileEntry.previewKind: images use <img> (icon fallback on error), video shows a captured first frame, PDF shows a pdf.js page-1 render, and audio/other show a type icon + extension. Fixes the multi-file preview across all modalities.
Verified in the browser for image/PDF/audio/video.
* fix(modality): make pipeline, batch validation, save/upload, previews & UI modality-aware
The app grew up image-only; several paths still assumed image. They now dispatch on the tool/file modality (image/video/audio/document/file):
- pipeline /execute + /batch: validate+decode input via inputHandlerFor(modality) instead of validateImageBuffer, so PDF/audio/video/data pipelines work (were rejected 'Invalid image').
- batch: non-image inputs now get per-modality validation (ffprobe/qpdf) before the worker instead of passing through unchecked.
- files /upload, user-files /save-result + /thumbnail: accept non-image files (MIME from extension; video-poster / pdf-first-page thumbnails).
- postprocess CONTENT_TYPE_TO_EXT: cover video/audio/pdf/text/zip so output extensions are corrected for all modalities.
- worker pipeline-finalize: attach result payload to the complete SSE event so the sync-window-timeout fallback still delivers a download.
- frontend: batch-ZIP blob MIME by extension (not svg-only); modality-neutral fallback labels/filenames; 'smaller file' not 'smaller image'.
Found via a codebase-wide image-only-assumption audit. Verified: PDF/audio/video pipelines + batch now work; image paths unchanged. canBrowserPreview kept image-only by design (non-image is rendered by dedicated displayMode viewers).
* fix(pipeline): generate a modality-aware preview for pipeline results
processPipelineFinalize now derives the output content type from its extension and runs generatePreview (video poster / pdf first page / image thumb), sets previewRef on the result, and surfaces previewUrl in the /execute sync response and the SSE complete event (via buildLegacyResultPayload). Pipeline outputs get a preview like single-tool results instead of always returning previewUrl: undefined.
Verified: PDF pipeline -> previewUrl returns a valid PNG first-page render; png pipeline correctly has no previewUrl; audio/video/multi-step pipelines all 200.
* fix(worker): auto-save a new library version when processing a library file
The worker hardcoded savedFileId = undefined ('No auto-save') even though the whole versioning feature was wired around it: the frontend sends fileId for library files and reads result.savedFileId, tool-factory threads fileId into ToolJobData, and autoSaveToLibrary implements the new-version save -- but the worker never called it (dead code from the tool-first-workflow merge). processToolJob now calls autoSaveToLibrary with data.fileId; without a fileId it is a no-op, so tool-first uploads are unchanged.
Verified: processing a library PDF with fileId creates version 2 (parent linked, toolChain appended, savedFileId returned); processing without fileId saves nothing.
* fix(library): ownership check + modality-aware dimensions in autoSaveToLibrary
- Only create a new version when the requester owns the parent (parent.userId === opts.userId); prevents versioning another user's file via a known fileId.
- Dimensions are modality-aware: sharp for images, ffprobe (probeMedia) for video, null for audio/document. Previously sharp-only, so non-image versions always got null dims.
* fix(ai): thread fileId + real userId through the 16 AI tool routes
AI custom routes parsed neither the fileId multipart field nor the authenticated user (they hardcoded userId: null), so processing a library file via an AI tool never created a new version, and AI jobs were unattributed. Each route now parses fileId like clientJobId and passes getAuthUser(request)?.id as userId to enqueueToolJob.
Verified: ocr-pdf on a library PDF creates a new version (v2); the ownership check still denies cross-user versioning.
* fix(media): mux container-correct codecs in video tools
Video tools hardcoded H.264 (and AAC) while keeping the input's container extension, so a .webm input produced an invalid file (ffmpeg exit 234: H.264 cannot be muxed into WebM). Add shared videoEncodeArgsForContainer/audioEncodeArgsForContainer helpers (vp9+opus for webm, theora+vorbis for ogv, h264+aac otherwise) and apply them across 14 tools; re-encode audio to AAC in burn-subtitles (forced mp4). Adds a webm regression test for change-fps.
* fix(eraser): recover Object Eraser when its progress SSE drops
The eraser used a bespoke EventSource with no recovery, so a dropped SSE left the UI stuck at ~25% forever even though the backend job had finished and saved its result. Add a resilient subscription (reconnect on tab refocus, which replays the cached terminal frame; 5-minute stall timeout) mirroring the standard processor's PR #203/#204 recovery.
* feat(ui): rename the Documents modality to PDF and Data to Files
Updates modality display names, the home-page tabs, the tool-page breadcrumb, and the homePage.documents/data + modalities labels across all 21 locales. URL slugs are unchanged for link stability.
* feat(compress-pdf): add quality and target-size compression modes
Mirror the image Compress tool: a quality slider (1-100) and a target file size, replacing the screen/ebook/printer preset. Adds gsCompressPdfQuality to doc-engine (quality maps to image downsample DPI, the dominant size lever for PDFs); target-size binary-searches the DPI for the highest quality under the target. The frontend reuses the shared CompressControls component, so no new translation strings are needed.
* feat(ocr-pdf): show the PDF preview and extracted text side by side
ocr-pdf fell back to the image viewer, which cannot render a PDF, so the right pane showed 'Preview not available' and the extracted text was only a download. It now uses a custom results view (custom-results display mode) rendering the input PDF via pdf.js (DocumentView gains an inputOnly prop, since the tool's output is a .txt) next to the extracted OCR text, with a copy button.
* feat(ui): link the modality breadcrumb to its tools tab
The modality segment of the tool breadcrumb (PDF, Image, Video, Audio, Files) is now a link to /?modality=<tab>. The home page reads the param, activates the matching tab, and cleans the URL, so it returns to the existing Tools page filtered to that modality without a new page. Handles the file modality whose tab key is 'data'.
* feat(circle-crop): add zoom/offset framing, border, background, and output size
Upgrade the circle-crop tool from a bare centered crop into a framing and
styling tool. New settings (all backward-compatible with the old empty
payload):
- zoom (1-5x) plus offsetX/offsetY (0-1) to control how tight the circle is
and where it sits in the source image
- borderWidth (0-200px) plus borderColor for an optional ring
- background: transparent (clear corners) or a hex fill
- outputSize for a square output; omitted keeps native size
The settings panel gains an inline draggable circular preview that mirrors
the framing live, a zoom slider, a border slider with color, a
transparent/color background toggle, and an output-size field. Adds an
integration test covering output size, border, and a solid background.
* feat(image-tools): flesh out five thin tools (gif-webp, histogram, favicon, color-palette, lqip)
Tier A of the image-tool depth pass. Each of these was as bare as the old
circle-crop (empty settings, opaque or invisible output). Now:
- gif-webp: quality, lossless, and resize-percent controls; shows before/after size
- histogram: returns full per-channel bins + stats; the settings panel renders an
inline interactive histogram with R/G/B/Luma toggles, linear/log scale, and a
mean/median/stdev readout (server PNG still downloadable)
- favicon: background fill, padding, corner-radius, theme color, and a per-size
checklist, with a live preview grid; the route applies the styling and honors
the size filter
- color-palette: count (2-16) and hex/rgb/hsl format controls, median-cut
extraction, a palette strip, and CSS/JSON export
- lqip-placeholder: blur/pixelate/solid strategies, format and quality; the
output panel now surfaces the data URI with copy plus HTML/CSS snippets and a
preview (previously the deliverable was never shown)
Also expose resultPayload from useToolProcessor so a tool can render the route's
extra result fields (histogram bins, lqip data URI) in its own panel. Updates the
five integration tests to cover the new settings.
* feat(image-tools): deepen five thin tools (duotone, vignette, pixelate, background-replace, blur-background)
Tier B of the image-tool depth pass.
- duotone: preset palettes, an intensity slider that blends the duotone with
the original, and a true live duotone preview (a self-contained grayscale +
lighten/darken overlay so the pane filter cannot wash it out)
- vignette: radius, softness, roundness, and center-x/y controls driving a
rebuilt radial gradient, with a matching live overlay
- pixelate: a selection mode that exposes the route's region support via a
draggable box over the image plus width/height sliders, so a face or plate
can be pixelated in isolation
- background-replace: gradient backgrounds, edge feather, and webp output on top
of the existing solid color; now shown before/after
- blur-background: edge feather and webp output; now shown before/after
The live previews for duotone and vignette needed onImageStyle to mount the
overlay branch in image-viewer. The duotone intensity blend and both AI tools'
edge feather were rewritten to splice the alpha channel through raw buffers;
joinChannel did not reliably re-tag the merged channel as alpha and a
raw-without-encoder buffer broke the next decode. Updates the five integration
tests.
* fix(data): rename Files modality to Data + 20 Data-tool bug fixes (#247)
* fix(ui): restore the Data modality name (revert Files rename)
The 'file' modality reverts to the 'Data' label in modality.ts, the home-page tab, and the tools.data + documentsAndFiles i18n keys across all 21 locales. The separate Documents to PDF rename is kept. The URL slug was already /data, so name and slug realign; the tool breadcrumb follows modality.ts automatically.
* fix(create-zip): require at least two files before enabling submit
create-zip enabled its submit button with a single file, but the backend rejects fewer than two files ('Zipping needs at least two files'), producing a 422 error. Gate the button on files.length >= 2 to match the sibling merge-csvs tool. Found during the Data-modality QA sweep.
* fix(data): resolve 17 bugs found in a deeper Data-tool review
Crashes (threw an internal error on otherwise-valid input):
- csv-json: a primitive JSON array like [1,2,3] threw "Unable to serialize"; now a clear error.
- json-xml: a null or primitive JSON root crashed the XML builder; now a clear 4xx.
- yaml-json: an empty or comment-only YAML returned undefined and threw on Buffer.from; now emits null.
Data loss / wrong output:
- csv-json: nested objects rendered as "[object Object]" (now serialized to JSON); heterogeneous objects dropped columns (now the union of all keys).
- xml-to-csv: leaked fast-xml-parser markers ("@_" on attributes, "#text") into CSV headers (now cleaned); a single-record XML failed to tabulate (now a 1-row table); heterogeneous records dropped columns (now the union of all keys).
- csv-excel: xlsx date cells were rendered in the server timezone via Date.toString (now ISO 8601, round-trippable).
- create-zip and extract-zip: filename/basename collisions overwrote zip entries and silently lost a file; dedup now checks generated names and guarantees uniqueness.
- chart-maker: negative values produced invalid/degenerate SVG that Sharp silently dropped; now rejected with a clear message.
Empty output / validation:
- split-csv: a header-only CSV produced an empty zip; now errors with "No data rows to split".
- extract-zip: a directory-only zip produced an empty zip; now errors with "No extractable files found".
- create-zip and merge-csvs: a single-file request fell through to the worker and returned 422; the factory now supports minInputs and returns 400 pre-enqueue.
UI:
- review-panel: the result card showed "Saved +X%" when the output grew; the savings row now appears only when the file is actually smaller (Original/Processed sizes always shown).
Found via two adversarial code-review passes over the 10 Data routes. All 24 fix + regression checks pass against a fresh Docker stack on :1359.
* fix(data): clean 400 for unsafe-zip entries; drop header on split keepHeader=false
- tool-factory: add an opt-in preValidate hook that runs after input prep and
before enqueue. Throwing InputValidationError there returns its statusCode
(400) instead of the worker's generic 422. BullMQ loses the error class across
the job boundary, so InputValidationErrors thrown in the worker cannot be
mapped to their status; pre-enqueue validation can.
- extract-zip: validate entry paths via preValidate, rejecting path-traversal
and absolute-path archives (and unreadable/corrupt zips) with a clear 400. The
processV2 guards remain as defense-in-depth for the pipeline/batch path.
- split-csv: keepHeader=false now drops the header (parts contain only data
rows) instead of keeping it as the first data row of part-1.
Verified against a fresh Docker stack: unsafe / absolute / corrupt zips -> 400,
normal zip still 200; split keepHeader=false drops the header while true repeats
it in each part. No regressions across 51 fix + scenario checks.
* feat(image-tools): deepen image-pad and sprite-sheet, fix sprite-sheet multi-file submit
Tier C of the image-tool depth pass.
- image-pad: a custom W:H ratio alongside the presets, a background mode
(solid color, transparent, or an Instagram-style blurred cover fill), and an
extra padding margin. The settings panel gains a real live preview of the
padded canvas (it previously declared live-preview but rendered nothing) via
onImageStyle + onImageOverlay.
- sprite-sheet: PNG/WebP/JPEG output with a quality control, and the coordinate
map it already computes is now returned and surfaced as Copy CSS (per-frame
background-position rules) and Copy JSON exports.
Also fix a pre-existing sprite-sheet bug: with more than one image the panel
called processAllFiles, fanning out to the per-file batch route (422). It now
calls processFiles, which packs all images into a single sheet request (it is a
MULTI_FILE tool). Updates both integration tests.
* fix(media): preserve source sample rate after loudnorm (#243)
ffmpeg's loudnorm filter runs internally at 192 kHz and emits 192 kHz
unless the chain resamples back. normalize-audio and video-loudnorm
therefore produced 192 kHz output (4.3x larger files) regardless of the
input rate. Append aresample to restore the input's sample rate.
runMediaTool now exposes the input audio sample rate to its args callback.
* fix(color-palette): collapse solid-color images to one swatch
The median-cut bucket selector started bestRange at -1, so a uniform bucket
(range 0) still satisfied the > comparison and kept splitting, yielding N
identical swatches for a solid-color image. Start at 0 so only buckets with
real color spread are split.
* fix(lint): annotate implicit-any lets in saml and user-files
biome noImplicitAnyLet flagged the bare let in saml.ts (profile) and user-files.ts (stream); add derived type annotations (type-only, no behavior change). Latent on main via the turbo lint cache; surfaced when the Data changes busted the apps/api lint cache.
ffmpeg's loudnorm filter runs internally at 192 kHz and emits 192 kHz
unless the chain resamples back, so normalize-audio produced 192 kHz
output (~4.3x larger files) regardless of the input rate. Append
aresample to restore the input's sample rate; runMediaTool now exposes
the input audio sample rate to its args callback.
* fix(ssrf): honor lookup all-option so DNS-pinned fetch works on Node 22
createPinnedAgent's custom lookup always called back in single-address
form. Node 22 invokes the agent lookup with { all: true }, so the address
arrived as undefined and every URL fetch failed with "Invalid IP address:
undefined" -- breaking the URL-import feature entirely. Return the pinned
IP as an array when all is requested; the connection is still pinned to the
SSRF-validated public IP (no DNS-rebinding regression).
* fix(url-import): accept non-image modalities
fetch-urls validated every fetched file as an image and ran a Sharp
preview, so audio/video/document URL imports were rejected. Try image
validation, accept non-image media (typed from the HTTP content-type),
and skip the image preview for non-images. SSRF + size limits unchanged.
The multi-file thumbnail strip rendered an <img> with the file blob URL
for every entry, so audio, video, and document files showed a broken
image icon with the filename as alt text instead of a preview.
Render an <img> only when there is a real image to show (a processed
preview, a processed image output, or an image-modality original).
Otherwise show a modality icon (waveform / film / document) plus the
file's format label, using the entry's existing previewKind.
* fix(batch): validate non-image inputs by modality
Batch processing ran validateImageBuffer and the image-only decode chain
on every uploaded file, so audio, video, and document tools rejected all
inputs with "Invalid image: Unrecognized image format" and returned
422 "All files failed processing".
Resolve the tool's modality and route non-image files through their own
input handler (inputHandlerFor(modality).prepare), mirroring the
single-file path. The image batch path is unchanged.
* fix(pipeline): validate non-image inputs by modality
The pipeline /execute and /batch routes validated every upload with
validateImageBuffer and ran the image-only decode chain, so audio,
video, and document pipelines were rejected with "Invalid image:
Unrecognized image format".
Resolve the input modality from the first step's tool and route
non-image inputs through their modality handler, mirroring the batch
and single-file paths. Image pipelines are unchanged.
Reframe the landing page around the self-hosted enterprise buyer.
Hero
- Rotating-modality headline with a fixed "Nothing leaves your network"
promise (pure CSS, no client JS) and an enterprise subtitle
- Instant, modality-aware tool search (multi-word) linking to per-tool pages
- Modality cards with live counts; stat strip (150+ tools, GitHub stars,
100K+ image pulls, 20+ languages); 7 enterprise trust badges
- Removed the Docker terminal and the hero CTAs
Sections
- "Built for enterprise deployment" redesigned as a security control plane
(category-tagged 4-up grid, audit-ready frameworks row)
- "Built for regulated environments" polished with numbered kickers and an
accurate Docker Compose deployment description
- ToolGrid is browse-only now (dropped its redundant search) with a clearer heading
Fixes and infra
- Per-tool pages render the real tool icon instead of a placeholder square
- Navbar GitHub stars fetch live client-side with a 1-hour localStorage cache
- Stats auto-update from the GitHub and Docker Hub APIs at build time
- Enterprise tab title and meta description
Sharp 0.35.1 moved FormatEnum to a namespace export and removed "avif"
from FormatEnum (now a separate literal in toFormat). BullMQ 5.78.1
bundles ioredis 5.10.1 while we have 5.11.1, causing structural type
mismatch. Also fixes new Biome 1.9 lint rules.
Resolved conflict in worker.ts: kept remote refactored worker
(v2 process, scratch paths, extra outputs, metrics) and re-applied
the auto-save removal from the feature branch.
Three places were using currentEntry?.processedFilename directly
without the processedFileName fallback (which parses the download URL):
- Video-to-image output detection (gif/webp check)
- Browser previewable check (canBrowserPreview)
- Non-native format detection (already fixed in prior commit)
All now consistently use processedFileName as fallback when
processedFilename is empty on the file entry.
* feat(tracing): add OpenTelemetry dependencies and --import preload flag
* feat(enterprise): add distributed_tracing feature gate
* feat(tracing): add SDK bootstrap with enterprise gating
* fix(tracing): correct test coverage for enterprise-unavailable path and prevent double-init
Test 2 now mocks @snapotter/enterprise to throw an import error, exercising
the catch block in the preload. Test 3 imports with no endpoint so the preload
is a no-op, avoiding leaked SDK from double-initialization. Added idempotency
guard to initTracing() as a safety net.
* feat(tracing): add Pino trace mixin and shared logger
When OTel tracing is active, every Pino log line now includes traceId,
spanId, and traceFlags fields for log-to-trace correlation. The mixin
is a no-op when no SDK is registered (community users).
* feat(tracing): add _otel to ToolJobData and inject trace context at enqueue
Add optional _otel carrier field to ToolJobData for W3C trace context
propagation across BullMQ job boundaries. When an active OTel span exists,
propagation.inject() writes traceparent/tracestate into the job data before
queue.add(). When no SDK is registered (community edition), the carrier
stays empty and _otel remains undefined -- zero overhead.
* feat(tracing): extract trace context and create spans in BullMQ worker
* feat(tracing): inject trace context into Python sidecar calls
* feat(tracing): add trace context extraction to Python sidecar
* feat(tracing): add shutdownTracing to graceful shutdown sequence
* feat(tracing): enrich HTTP spans with tool_id and user_id attributes
* docs: add OpenTelemetry env var documentation to .env.example
* test(tracing): add lifecycle integration tests for trace propagation
* fix(tracing): inject trace context into pipeline and batch flow jobs
* fix(tracing): add sidecar.execute Node-side span and remove unnecessary comment
Wraps PythonDispatcher.run() with a sidecar.execute span on the Node
side so traces show the full round-trip (Node span -> Python span).
Also removes an obvious comment from logger.ts.
NonNativePreview now accepts either a File (pre-processing) or a src
URL (post-processing). After processing a non-native video (e.g.,
muting an AVI), the preview button fetches the processed result from
its URL and sends it for transcoding, instead of showing the broken
MediaPlayerView.
Instead of showing a broken video player error for formats like AVI,
MKV, WMV that browsers can't play, show a clean file info card with
icon, filename, format, and size. Message says "Preview will be
available after processing" which is accurate -- the processed output
is typically in a browser-friendly format.
Also cleaned up the post-processing non-previewable card to match
the same visual style.
- Download stays primary (full-width button)
- Save to Files demoted to subtle text link below download
- "Adjust settings" and "New file" are side-by-side compact buttons
- "Back to Tools" faded to very subtle (breadcrumb handles this)
- Clearer visual hierarchy: primary -> secondary -> tertiary actions
The All tab now shows tools organized by modality (Image, Video,
Audio, Documents, Data) with each section showing a colored icon,
tool count, and collapse toggle. Categories are nested within each
modality. Collapse state persists in localStorage so enterprise
users can hide modalities they don't use.
Modality tabs still show the flat category view for focused browsing.
ArrowUp/ArrowDown to move between files, Home/End to jump to
first/last. Selected file scrolls into view automatically.
List container is focusable and keyboard-navigable.
- Video files: inline video player with controls
- Audio files: waveform visualization with playback (wavesurfer.js)
- Image files: thumbnail preview (existing behavior)
- Documents/data: file type icon with format label
When importing from Files for a specific tool, all files are shown
but incompatible ones (wrong modality) are greyed out and unclickable.
This gives users full visibility of their library while making it
clear which files can be used.
When registryEntry.accept is undefined, fall back to tool.acceptedInputs
to derive the file filter. This ensures image tools reject audio/video
files in the file picker, and vice versa.
When clicking "Import from Files" on an image tool, only image files
are shown in the library. Video tools see only videos, audio tools
only audio. Uses MIME type prefix filtering (image/, video/, audio/).
Document and data tools see all files since their MIME types vary.
Theme toggle (sun/moon) and language selector (globe + dropdown) now
live in the top nav's right section alongside help and avatar.
Removed the floating Footer component from the home page since its
contents are now in the nav bar.
The upload endpoint was rejecting non-image files via validateImageBuffer.
Now non-image files skip validation and use the MIME type from the
multipart upload. Width/height are null for non-image files.
- Disabled auto-save of processed files to library (worker no longer
calls autoSaveToLibrary)
- Added "Save to Files" button in the review panel for explicit saving
- Files library upload area now accepts all file types, not just images
- Removed "Drop images here" hardcoded text, replaced with i18n
- Removed image-only file filter from library upload
- Import from Files now navigates back to the tool page with the
selected file loaded (uses selectForTool router state)
- Tool page skips file store reset when arriving from the library
- Removed "Open in Pipeline" button from file details and file list
- Button text changes to "Select File" when in file-selection mode
- Removed Automate/Editor/Files links from top nav on tool pages,
keeping only the breadcrumb + help + avatar for a cleaner look
- Added "Import from Files" link on the tool dropzone that navigates
to the file library, letting users import previously processed files
Changed undoProcessing() to reset() on toolId change so files don't
persist when navigating from one tool to another. Previously a video
file from a video tool would carry over to an image tool.
When a video tool produces a non-video output (gif, webp, png), the
media player can't render it. Now detects the processed file extension
and switches to ImageViewer for image outputs while keeping the video
player for the input preview.
video-to-webp, video-to-frames, auto-subtitles, extract-subtitles,
video-metadata, and audio-metadata were using display modes that
render via ImageViewer, which can't display video/audio files
(shows "Preview not available"). Changed to media-player so they
get proper video player or waveform visualization.