From d50e8e42a7f6b617ed4754c8ef6605192023ff57 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Tue, 16 Jun 2026 15:16:13 +0800 Subject: [PATCH] QA + image-tool depth pass: codec/eraser/PDF fixes, modality renames, 13 image tools deepened (#249) * 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=. 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. --- apps/api/src/lib/media-tool.ts | 36 ++- apps/api/src/plugins/saml.ts | 2 +- apps/api/src/routes/tool-factory.ts | 44 +++ apps/api/src/routes/tools/aspect-pad.ts | 18 +- .../src/routes/tools/background-replace.ts | 78 ++++- apps/api/src/routes/tools/blur-background.ts | 39 ++- apps/api/src/routes/tools/blur-pad.ts | 18 +- apps/api/src/routes/tools/burn-subtitles.ts | 2 +- apps/api/src/routes/tools/change-fps.ts | 16 +- apps/api/src/routes/tools/chart-maker.ts | 5 + apps/api/src/routes/tools/circle-crop.ts | 97 ++++-- apps/api/src/routes/tools/color-palette.ts | 186 +++++++++-- apps/api/src/routes/tools/compress-pdf.ts | 53 ++- apps/api/src/routes/tools/create-zip.ts | 21 +- apps/api/src/routes/tools/crop-video.ts | 18 +- apps/api/src/routes/tools/csv-excel.ts | 4 +- apps/api/src/routes/tools/csv-json.ts | 24 +- apps/api/src/routes/tools/duotone.ts | 31 +- apps/api/src/routes/tools/extract-zip.ts | 55 +++- apps/api/src/routes/tools/favicon.ts | 109 +++++-- apps/api/src/routes/tools/gif-webp.ts | 29 +- apps/api/src/routes/tools/histogram.ts | 81 ++++- apps/api/src/routes/tools/image-pad.ts | 85 ++++- apps/api/src/routes/tools/json-xml.ts | 23 +- apps/api/src/routes/tools/lqip-placeholder.ts | 57 +++- apps/api/src/routes/tools/merge-csvs.ts | 1 + apps/api/src/routes/tools/pixelate.ts | 13 +- apps/api/src/routes/tools/replace-audio.ts | 12 +- apps/api/src/routes/tools/resize-video.ts | 16 +- apps/api/src/routes/tools/reverse-video.ts | 31 +- apps/api/src/routes/tools/rotate-video.ts | 16 +- apps/api/src/routes/tools/split-csv.ts | 11 +- apps/api/src/routes/tools/sprite-sheet.ts | 42 ++- apps/api/src/routes/tools/trim-video.ts | 20 +- apps/api/src/routes/tools/video-color.ts | 16 +- apps/api/src/routes/tools/video-loudnorm.ts | 20 +- apps/api/src/routes/tools/video-speed.ts | 25 +- apps/api/src/routes/tools/vignette.ts | 30 +- apps/api/src/routes/tools/watermark-video.ts | 17 +- apps/api/src/routes/tools/xml-to-csv.ts | 48 ++- apps/api/src/routes/tools/yaml-json.ts | 5 +- apps/api/src/routes/user-files.ts | 2 +- .../src/components/common/review-panel.tsx | 26 +- apps/web/src/components/layout/app-layout.tsx | 2 +- apps/web/src/components/layout/top-nav.tsx | 30 +- .../tools/background-replace-settings.tsx | 202 ++++++++++-- .../tools/blur-background-settings.tsx | 56 +++- .../components/tools/circle-crop-settings.tsx | 245 ++++++++++++-- .../tools/color-palette-settings.tsx | 126 +++++++- .../tools/compress-pdf-settings.tsx | 30 +- .../components/tools/create-zip-settings.tsx | 4 +- .../src/components/tools/document-view.tsx | 8 +- .../src/components/tools/duotone-settings.tsx | 114 ++++++- .../tools/erase-object-settings.tsx | 306 ++++++++++-------- .../src/components/tools/favicon-settings.tsx | 229 ++++++++++++- .../components/tools/gif-webp-settings.tsx | 106 +++++- .../components/tools/histogram-settings.tsx | 153 ++++++++- .../components/tools/image-pad-settings.tsx | 256 +++++++++++++-- .../tools/lqip-placeholder-settings.tsx | 203 ++++++++++-- .../web/src/components/tools/ocr-pdf-view.tsx | 98 ++++++ .../components/tools/pixelate-settings.tsx | 253 ++++++++++++++- .../tools/sprite-sheet-settings.tsx | 168 +++++++++- .../components/tools/vignette-settings.tsx | 162 +++++++++- apps/web/src/hooks/use-tool-processor.ts | 8 + apps/web/src/lib/tool-display-modes.ts | 8 +- apps/web/src/lib/tool-registry.tsx | 5 +- apps/web/src/pages/home-page.tsx | 16 +- apps/web/src/pages/tool-page.tsx | 4 +- packages/doc-engine/src/ghostscript.ts | 35 ++ packages/doc-engine/src/index.ts | 1 + packages/shared/src/i18n/ar.ts | 4 +- packages/shared/src/i18n/de.ts | 4 +- packages/shared/src/i18n/en.ts | 4 +- packages/shared/src/i18n/es.ts | 4 +- packages/shared/src/i18n/fr.ts | 4 +- packages/shared/src/i18n/hi.ts | 4 +- packages/shared/src/i18n/id.ts | 4 +- packages/shared/src/i18n/it.ts | 4 +- packages/shared/src/i18n/ja.ts | 4 +- packages/shared/src/i18n/ko.ts | 4 +- packages/shared/src/i18n/nl.ts | 4 +- packages/shared/src/i18n/pl.ts | 4 +- packages/shared/src/i18n/pt-BR.ts | 4 +- packages/shared/src/i18n/ru.ts | 4 +- packages/shared/src/i18n/sv.ts | 4 +- packages/shared/src/i18n/th.ts | 4 +- packages/shared/src/i18n/tr.ts | 4 +- packages/shared/src/i18n/uk.ts | 4 +- packages/shared/src/i18n/vi.ts | 4 +- packages/shared/src/i18n/zh-CN.ts | 4 +- packages/shared/src/i18n/zh-TW.ts | 4 +- packages/shared/src/modality.ts | 8 +- tests/integration/background-replace.test.ts | 90 ++++++ tests/integration/blur-background.test.ts | 57 +++- tests/integration/change-fps.test.ts | 44 ++- tests/integration/circle-crop.test.ts | 45 +++ tests/integration/color-palette.test.ts | 108 ++++++- tests/integration/compress-pdf.test.ts | 27 +- tests/integration/duotone.test.ts | 59 +++- tests/integration/favicon.test.ts | 81 +++++ tests/integration/gif-webp.test.ts | 61 ++++ tests/integration/histogram.test.ts | 42 ++- tests/integration/image-pad.test.ts | 107 +++++- tests/integration/lqip-placeholder.test.ts | 32 +- tests/integration/pixelate.test.ts | 43 ++- tests/integration/sprite-sheet.test.ts | 64 ++++ tests/integration/vignette.test.ts | 40 +++ 107 files changed, 4546 insertions(+), 756 deletions(-) create mode 100644 apps/web/src/components/tools/ocr-pdf-view.tsx diff --git a/apps/api/src/lib/media-tool.ts b/apps/api/src/lib/media-tool.ts index c2c74d20..d7772f32 100644 --- a/apps/api/src/lib/media-tool.ts +++ b/apps/api/src/lib/media-tool.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { probeMedia, runFfmpeg } from "@snapotter/media-engine"; +import { probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine"; import type { ToolProcessCtxV2 } from "../routes/tool-factory.js"; const EXT_VIDEO_CONTENT_TYPES: Record = { @@ -15,6 +15,40 @@ export function videoContentType(ext: string): string { return EXT_VIDEO_CONTENT_TYPES[ext.toLowerCase()] || "video/mp4"; } +/** + * Video encode args valid for the given OUTPUT container extension. + * WebM accepts only VP8/VP9/AV1, OGV only Theora; everything else + * (mp4/mov/mkv/avi/ts) gets H.264. Hardcoding H.264 into a preserved + * non-mp4 container is a real bug: ffmpeg cannot mux H.264 into WebM and + * fails the header write ("Invalid argument", exit 234). Arg sets mirror + * the tested convert-video encoders. + */ +export function videoEncodeArgsForContainer(ext: string): string[] { + const lower = ext.toLowerCase(); + if (lower === ".webm") { + return ["-c:v", resolveEncoder("vp9"), "-crf", "30", "-b:v", "0", "-row-mt", "1"]; + } + if (lower === ".ogv" || lower === ".ogg") { + // Theora has no HW-accel path; use libtheora directly. + return ["-c:v", "libtheora", "-q:v", "7"]; + } + return ["-c:v", resolveEncoder("h264"), "-crf", "20", "-preset", "medium", "-pix_fmt", "yuv420p"]; +} + +/** + * Audio encode args valid for the given OUTPUT container, for tools that + * must (re-)encode the audio stream (tempo/reverse/loudnorm/replace). + * WebM needs Opus, OGV needs Vorbis, everything else gets AAC. When the + * source audio stream is untouched and the container is preserved, use + * ["-c:a", "copy"] directly instead -- it is always valid there. + */ +export function audioEncodeArgsForContainer(ext: string): string[] { + const lower = ext.toLowerCase(); + if (lower === ".webm") return ["-c:a", resolveEncoder("opus")]; + if (lower === ".ogv" || lower === ".ogg") return ["-c:a", "libvorbis"]; + return ["-c:a", resolveEncoder("aac")]; +} + const EXT_AUDIO_CONTENT_TYPES: Record = { ".mp3": "audio/mpeg", ".wav": "audio/wav", diff --git a/apps/api/src/plugins/saml.ts b/apps/api/src/plugins/saml.ts index e68ce3a8..da65550d 100644 --- a/apps/api/src/plugins/saml.ts +++ b/apps/api/src/plugins/saml.ts @@ -95,7 +95,7 @@ export async function registerSaml(app: FastifyInstance): Promise { const saml = getSamlInstance(); const audit = auditFromRequest(request); - let profile; + let profile: Awaited>["profile"]; try { const result = await saml.validatePostResponseAsync(request.body as Record); profile = result.profile; diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 8b3ca7a2..8311d5d2 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -73,6 +73,16 @@ export interface ToolRouteConfig { * inputRefs in arrival order. */ maxInputs?: number; + /** Minimum number of file parts required (default 1). Fewer returns HTTP 400. */ + minInputs?: number; + /** + * Optional pre-enqueue validation hook. Receives the prepared input buffers + * and validated settings; throw InputValidationError to reject with its + * statusCode (default 400) before any job is enqueued (vs a worker 422). + */ + preValidate?: (ctx: { + inputs: { filename: string; buffer: Buffer }[]; + }) => Promise | void; /** * Per-position input kind overrides for mixed-input tools (e.g. video + * subtitle). Input i validates with kind inputKinds[Math.min(i, len-1)]. @@ -110,6 +120,10 @@ export interface ToolRouteConfig { export interface AnyToolRouteConfig { toolId: string; maxInputs?: number; + minInputs?: number; + preValidate?: (ctx: { + inputs: { filename: string; buffer: Buffer }[]; + }) => Promise | void; inputKinds?: ("video" | "audio" | "image" | "subtitle")[]; settingsSchema: z.ZodType; process: ( @@ -215,6 +229,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const jobId = randomUUID(); const maxInputs = config.maxInputs ?? 1; + const minInputs = config.minInputs ?? 1; let filename = "image"; let settingsRaw: string | null = null; let fileId: string | null = null; @@ -306,6 +321,14 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig return reply.status(400).send({ error: "No image file provided" }); } + // Require the tool's minimum number of files (e.g. create-zip / merge-csvs + // need 2). Returns 400 pre-enqueue instead of a 422 from the worker. + if (received.length < minInputs) { + return reply.status(400).send({ + error: `This tool needs at least ${minInputs} files`, + }); + } + const reportProgress = (percent: number, stage?: string) => { if (!clientJobId) return; updateSingleFileProgress({ @@ -361,6 +384,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // Prepare all files through the modality input handler const inputRefs: string[] = []; + const preparedInputs: { filename: string; buffer: Buffer }[] = []; for (let i = 0; i < received.length; i++) { const upload = received[i]; let fileBuffer = await getObjectBuffer(upload.key); @@ -404,6 +428,10 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig if (i === 0) { filename = fname; } + + if (config.preValidate) { + preparedInputs.push({ filename: fname, buffer: fileBuffer }); + } } reportProgress(15, "Preparing..."); @@ -430,6 +458,22 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig return reply.status(400).send({ error: "Settings must be valid JSON" }); } + // Optional tool-specific pre-enqueue validation (e.g. zip-entry safety). + // Throwing InputValidationError here returns its statusCode (400) before + // any job is enqueued, instead of a generic 422 from the worker. + if (config.preValidate) { + try { + await config.preValidate({ inputs: preparedInputs }); + } catch (err) { + if (err instanceof InputValidationError) { + const body: Record = { error: err.message }; + if (err.details) body.details = err.details; + return reply.status(err.statusCode).send(body); + } + throw err; + } + } + // Guard: check if the tool's AI feature bundle is installed const bundleId = TOOL_BUNDLE_MAP[config.toolId]; if (bundleId && !isToolInstalled(config.toolId)) { diff --git a/apps/api/src/routes/tools/aspect-pad.ts b/apps/api/src/routes/tools/aspect-pad.ts index f38e9736..c063f4bd 100644 --- a/apps/api/src/routes/tools/aspect-pad.ts +++ b/apps/api/src/routes/tools/aspect-pad.ts @@ -1,8 +1,13 @@ import { extname, join } from "node:path"; -import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +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 { + runFfmpegWithProgress, + stageMediaInputs, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const TARGETS = { @@ -76,14 +81,7 @@ export function registerAspectPad(app: FastifyInstance) { inPath, "-vf", `pad=${cw}:${ch}:(ow-iw)/2:(oh-ih)/2:color=${c}`, - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), "-c:a", "copy", outPath, diff --git a/apps/api/src/routes/tools/background-replace.ts b/apps/api/src/routes/tools/background-replace.ts index ca519466..1d0fc2f6 100644 --- a/apps/api/src/routes/tools/background-replace.ts +++ b/apps/api/src/routes/tools/background-replace.ts @@ -2,11 +2,12 @@ import { randomUUID } from "node:crypto"; import { removeBackground } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; import { z } from "zod"; import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; import { enqueueToolJob } from "../../jobs/enqueue.js"; import { autoOrient } from "../../lib/auto-orient.js"; -import { compositeOnColor } from "../../lib/bg-effects.js"; +import { compositeOnColor, createGradientBackground } from "../../lib/bg-effects.js"; import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; @@ -14,13 +15,43 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j import { decodeHeic } from "../../lib/heic-converter.js"; import { receiveUpload } from "../../lib/upload-stream.js"; +const HEX_RE = /^#[0-9a-fA-F]{6}$/; + const settingsSchema = z.object({ - color: z - .string() - .regex(/^#[0-9a-fA-F]{6}$/) - .default("#ffffff"), + backgroundType: z.enum(["color", "gradient"]).default("color"), + color: z.string().regex(HEX_RE).default("#ffffff"), + gradientColor1: z.string().regex(HEX_RE).optional(), + gradientColor2: z.string().regex(HEX_RE).optional(), + gradientAngle: z.number().int().min(0).max(360).default(180), + feather: z.number().int().min(0).max(20).default(0), + format: z.enum(["png", "webp"]).default("png"), }); +/** + * Soften the alpha edges of a subject PNG by blurring its alpha channel. + * Keeps RGB intact; only the transparency boundary gets smoothed. + */ +async function featherEdges(subjectBuffer: Buffer, radius: number): Promise { + // Read the subject as raw RGBA plus a separately-blurred copy of its alpha, + // then overwrite the alpha channel in place. joinChannel does not reliably + // re-tag the merged channel as alpha, so we splice the raw bytes directly. + const { data: rgba, info } = await sharp(subjectBuffer) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const { data: blurredAlpha } = await sharp(subjectBuffer) + .extractChannel(3) + .blur(radius) + .raw() + .toBuffer({ resolveWithObject: true }); + for (let p = 3, a = 0; p < rgba.length; p += 4, a++) { + rgba[p] = blurredAlpha[a]; + } + return sharp(rgba, { raw: { width: info.width, height: info.height, channels: 4 } }) + .png() + .toBuffer(); +} + // -- AI job handler (runs inside the BullMQ worker) -- registerAiJobHandler("background-replace", async (input, data, ctx) => { const settings = settingsSchema.parse(data.settings); @@ -33,17 +64,46 @@ registerAiJobHandler("background-replace", async (input, data, ctx) => { ctx.report(Math.min(scaled, 80), stage); }); - ctx.report(85, "Compositing on color"); + // Feather alpha edges before compositing + let subject = subjectPng; + if (settings.feather > 0) { + ctx.report(82, "Feathering edges"); + subject = await featherEdges(subjectPng, settings.feather); + } - const result = await compositeOnColor(subjectPng, settings.color); + ctx.report(85, "Compositing background"); + + let composited: Buffer; + if (settings.backgroundType === "gradient") { + const meta = await sharp(subject).metadata(); + if (!meta.width || !meta.height) throw new Error("Cannot read subject dimensions"); + const gradBg = await createGradientBackground( + meta.width, + meta.height, + settings.gradientColor1 ?? "#ffffff", + settings.gradientColor2 ?? "#000000", + settings.gradientAngle, + ); + composited = await sharp(gradBg) + .composite([{ input: subject, blend: "over" }]) + .png() + .toBuffer(); + } else { + composited = await compositeOnColor(subject, settings.color); + } + + // Encode to requested output format + const fmt = settings.format; + const result = + fmt === "webp" ? await sharp(composited).webp({ lossless: true }).toBuffer() : composited; const base = data.filename.replace(/\.[^.]+$/, ""); - const outName = `${base}_bg.png`; + const outName = `${base}_bg.${fmt}`; return { buffer: result, filename: outName, - contentType: "image/png", + contentType: fmt === "webp" ? "image/webp" : "image/png", }; }); diff --git a/apps/api/src/routes/tools/blur-background.ts b/apps/api/src/routes/tools/blur-background.ts index 88983da4..fab81bdc 100644 --- a/apps/api/src/routes/tools/blur-background.ts +++ b/apps/api/src/routes/tools/blur-background.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { removeBackground } from "@snapotter/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; import { z } from "zod"; import { registerAiJobHandler } from "../../jobs/ai-handlers.js"; import { enqueueToolJob } from "../../jobs/enqueue.js"; @@ -16,6 +17,8 @@ import { receiveUpload } from "../../lib/upload-stream.js"; const settingsSchema = z.object({ intensity: z.number().int().min(1).max(100).default(50), + feather: z.number().int().min(0).max(20).default(0), + format: z.enum(["png", "webp"]).default("png"), }); // -- AI job handler (runs inside the BullMQ worker) -- @@ -32,15 +35,45 @@ registerAiJobHandler("blur-background", async (input, data, ctx) => { ctx.report(85, "Blurring background"); - const result = await blurBackground(input, subjectPng, settings.intensity); + // If feather > 0, soften the subject alpha edge before compositing. Read the + // subject as raw RGBA plus a separately-blurred copy of its alpha and + // overwrite the alpha bytes in place; joinChannel does not reliably re-tag the + // merged channel as alpha. + let compositeSubject = subjectPng; + if (settings.feather > 0) { + const { data: rgba, info } = await sharp(subjectPng) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const { data: blurredAlpha } = await sharp(subjectPng) + .extractChannel(3) + .blur(settings.feather) + .raw() + .toBuffer({ resolveWithObject: true }); + for (let p = 3, a = 0; p < rgba.length; p += 4, a++) { + rgba[p] = blurredAlpha[a]; + } + compositeSubject = await sharp(rgba, { + raw: { width: info.width, height: info.height, channels: 4 }, + }) + .png() + .toBuffer(); + } + const blurred = await blurBackground(input, compositeSubject, settings.intensity); + + // Encode in the requested output format + const fmt = settings.format; const base = data.filename.replace(/\.[^.]+$/, ""); - const outName = `${base}_blurbg.png`; + const outName = `${base}_blurbg.${fmt}`; + const contentType = fmt === "webp" ? "image/webp" : "image/png"; + const result = + fmt === "webp" ? await sharp(blurred).webp({ lossless: true }).toBuffer() : blurred; // blurBackground already returns PNG return { buffer: result, filename: outName, - contentType: "image/png", + contentType, }; }); diff --git a/apps/api/src/routes/tools/blur-pad.ts b/apps/api/src/routes/tools/blur-pad.ts index b04b12ec..c687ff01 100644 --- a/apps/api/src/routes/tools/blur-pad.ts +++ b/apps/api/src/routes/tools/blur-pad.ts @@ -1,8 +1,13 @@ import { extname, join } from "node:path"; -import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +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 { + runFfmpegWithProgress, + stageMediaInputs, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const TARGETS = { @@ -77,14 +82,7 @@ export function registerBlurPad(app: FastifyInstance) { "[v]", "-map", "0:a?", - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), "-c:a", "copy", outPath, diff --git a/apps/api/src/routes/tools/burn-subtitles.ts b/apps/api/src/routes/tools/burn-subtitles.ts index 3681dbbe..5468068b 100644 --- a/apps/api/src/routes/tools/burn-subtitles.ts +++ b/apps/api/src/routes/tools/burn-subtitles.ts @@ -57,7 +57,7 @@ export function registerBurnSubtitles(app: FastifyInstance) { "-pix_fmt", "yuv420p", "-c:a", - "copy", + resolveEncoder("aac"), outPath, ], info.durationS, diff --git a/apps/api/src/routes/tools/change-fps.ts b/apps/api/src/routes/tools/change-fps.ts index d4b0c64c..42bb8185 100644 --- a/apps/api/src/routes/tools/change-fps.ts +++ b/apps/api/src/routes/tools/change-fps.ts @@ -1,8 +1,11 @@ import { extname } from "node:path"; -import { resolveEncoder } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { + runMediaTool, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -28,14 +31,7 @@ export function registerChangeFps(app: FastifyInstance) { inPath, "-vf", `fps=${settings.fps}`, - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), "-c:a", "copy", out, diff --git a/apps/api/src/routes/tools/chart-maker.ts b/apps/api/src/routes/tools/chart-maker.ts index 91396e2a..9113d72e 100644 --- a/apps/api/src/routes/tools/chart-maker.ts +++ b/apps/api/src/routes/tools/chart-maker.ts @@ -223,6 +223,11 @@ export function registerChartMaker(app: FastifyInstance) { throw new Error("Column 2 must be numeric"); } } + // Negative values render as invalid/degenerate SVG (negative bar heights, + // backward pie arcs that Sharp silently drops); reject with a clear message. + if (data.some((point) => point.value < 0)) { + throw new Error("Chart values must be zero or greater"); + } let svg: string; switch (settings.kind) { diff --git a/apps/api/src/routes/tools/circle-crop.ts b/apps/api/src/routes/tools/circle-crop.ts index 7645012f..a3a8ba15 100644 --- a/apps/api/src/routes/tools/circle-crop.ts +++ b/apps/api/src/routes/tools/circle-crop.ts @@ -3,44 +3,97 @@ import sharp from "sharp"; import { z } from "zod"; import { createToolRoute } from "../tool-factory.js"; -const settingsSchema = z.object({}); +const settingsSchema = z.object({ + // Framing: zoom (>=1 crops tighter) + where the circle sits in the image (0..1). + zoom: z.number().min(1).max(5).default(1), + offsetX: z.number().min(0).max(1).default(0.5), + offsetY: z.number().min(0).max(1).default(0.5), + // Styling. + borderWidth: z.number().int().min(0).max(200).default(0), + borderColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default("#ffffff"), + // "transparent" leaves the corners clear; a hex fills them. + background: z + .string() + .regex(/^(transparent|#[0-9a-fA-F]{6})$/) + .default("transparent"), + // Final output dimension in px (square). Omitted = native size. + outputSize: z.number().int().min(16).max(4096).optional(), +}); + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + return { + r: Number.parseInt(hex.slice(1, 3), 16), + g: Number.parseInt(hex.slice(3, 5), 16), + b: Number.parseInt(hex.slice(5, 7), 16), + }; +} export function registerCircleCrop(app: FastifyInstance) { createToolRoute(app, { toolId: "circle-crop", settingsSchema, - process: async (inputBuffer, _settings, filename) => { + process: async (inputBuffer, settings, filename) => { const meta = await sharp(inputBuffer).metadata(); - const w = meta.width ?? 1; - const h = meta.height ?? 1; - const d = Math.min(w, h); + const W = meta.width ?? 1; + const H = meta.height ?? 1; - // Extract centered square - const left = Math.floor((w - d) / 2); - const top = Math.floor((h - d) / 2); + // The circle's bounding square, derived from zoom + offsets. + let d = Math.round(Math.min(W, H) / settings.zoom); + d = Math.max(8, Math.min(d, W, H)); + let left = Math.round((W - d) * settings.offsetX); + let top = Math.round((H - d) * settings.offsetY); + left = Math.max(0, Math.min(left, W - d)); + top = Math.max(0, Math.min(top, H - d)); + + const bw = Math.min(settings.borderWidth, Math.floor(d / 2)); + const canvas = d + 2 * bw; + + // Extract the square, mask it to a circle. const squareBuf = await sharp(inputBuffer) .extract({ left, top, width: d, height: d }) .toBuffer(); - - // Create SVG circle mask - const r = d / 2; - const mask = Buffer.from( - ``, + const circleMask = Buffer.from( + ``, ); - - // Composite with dest-in blend to mask - const buffer = await sharp(squareBuf) + const imgCircle = await sharp(squareBuf) .ensureAlpha() - .composite([{ input: await sharp(mask).resize(d, d).toBuffer(), blend: "dest-in" }]) + .composite([{ input: circleMask, blend: "dest-in" }]) .png() .toBuffer(); + // Compose: background, optional border ring, then the circular image. + const bg = + settings.background === "transparent" + ? { r: 0, g: 0, b: 0, alpha: 0 } + : { ...hexToRgb(settings.background), alpha: 1 }; + const layers: sharp.OverlayOptions[] = []; + if (bw > 0) { + const ring = Buffer.from( + ``, + ); + layers.push({ input: ring, left: 0, top: 0 }); + } + layers.push({ input: imgCircle, left: bw, top: bw }); + + let out = await sharp({ + create: { width: canvas, height: canvas, channels: 4, background: bg }, + }) + .composite(layers) + .png() + .toBuffer(); + + if (settings.outputSize) { + out = await sharp(out) + .resize(settings.outputSize, settings.outputSize, { fit: "fill" }) + .png() + .toBuffer(); + } + const base = filename.replace(/\.[^.]+$/, ""); - return { - buffer, - filename: `${base}_circle.png`, - contentType: "image/png", - }; + return { buffer: out, filename: `${base}_circle.png`, contentType: "image/png" }; }, }); } diff --git a/apps/api/src/routes/tools/color-palette.ts b/apps/api/src/routes/tools/color-palette.ts index ed6eb10b..df57a015 100644 --- a/apps/api/src/routes/tools/color-palette.ts +++ b/apps/api/src/routes/tools/color-palette.ts @@ -1,53 +1,160 @@ import type { FastifyInstance } from "fastify"; import sharp from "sharp"; +import { z } from "zod"; 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 { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -/** - * Simple k-means-like color quantization to extract dominant colors. - */ -function extractColors(pixels: Buffer, channelCount: number, maxColors: number): string[] { - // Build frequency map of quantized colors - const colorMap = new Map(); +const settingsSchema = z + .object({ + count: z.number().int().min(2).max(16).default(8), + format: z.enum(["hex", "rgb", "hsl"]).default("hex"), + }) + .default({}); - for (let i = 0; i < pixels.length; i += channelCount) { - // Quantize to reduce noise (round to nearest 16) - const r = Math.min(Math.round(pixels[i] / 16) * 16, 255); - const g = Math.min(Math.round(pixels[i + 1] / 16) * 16, 255); - const b = Math.min(Math.round(pixels[i + 2] / 16) * 16, 255); - const key = `${r},${g},${b}`; - colorMap.set(key, (colorMap.get(key) ?? 0) + 1); +// ── Color format helpers ───────────────────────────────────────── + +function rgbToHex(r: number, g: number, b: number): string { + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +function rgbToRgbString(r: number, g: number, b: number): string { + return `rgb(${r}, ${g}, ${b})`; +} + +function rgbToHsl(r: number, g: number, b: number): string { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + if (max === min) return `hsl(0, 0%, ${Math.round(l * 100)}%)`; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h = 0; + if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / d + 2) / 6; + else h = ((rn - gn) / d + 4) / 6; + return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`; +} + +function formatColor(r: number, g: number, b: number, fmt: "hex" | "rgb" | "hsl"): string { + if (fmt === "rgb") return rgbToRgbString(r, g, b); + if (fmt === "hsl") return rgbToHsl(r, g, b); + return rgbToHex(r, g, b); +} + +// ── Median-cut quantization ────────────────────────────────────── + +interface ColorBucket { + pixels: Array<[number, number, number]>; +} + +function rangeOfChannel(pixels: Array<[number, number, number]>, ch: 0 | 1 | 2): number { + let min = 255; + let max = 0; + for (const px of pixels) { + if (px[ch] < min) min = px[ch]; + if (px[ch] > max) max = px[ch]; } + return max - min; +} - // Sort by frequency and pick top colors - const sorted = [...colorMap.entries()].sort((a, b) => b[1] - a[1]); +function medianCut( + pixels: Array<[number, number, number]>, + maxColors: number, +): Array<{ r: number; g: number; b: number; count: number }> { + if (pixels.length === 0) return []; - // Filter similar colors (merge colors within distance 40) - const results: Array<{ r: number; g: number; b: number; count: number }> = []; - for (const [key, count] of sorted) { - const [r, g, b] = key.split(",").map(Number); - const tooClose = results.some( - (c) => Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b) < 48, - ); - if (!tooClose) { - results.push({ r, g, b, count }); + const buckets: ColorBucket[] = [{ pixels }]; + + // Split until we have enough buckets or can't split further + while (buckets.length < maxColors) { + // Pick the bucket with the widest channel range. bestRange starts at 0 so a + // uniform bucket (range 0) is never chosen -- otherwise a solid-color image + // would keep splitting into identical swatches. + let bestIdx = -1; + let bestRange = 0; + let bestCh: 0 | 1 | 2 = 0; + + for (let i = 0; i < buckets.length; i++) { + if (buckets[i].pixels.length < 2) continue; + for (const ch of [0, 1, 2] as const) { + const r = rangeOfChannel(buckets[i].pixels, ch); + if (r > bestRange) { + bestRange = r; + bestIdx = i; + bestCh = ch; + } + } } - if (results.length >= maxColors) break; + + if (bestIdx === -1) break; // nothing left to split + + const bucket = buckets[bestIdx]; + bucket.pixels.sort((a, b) => a[bestCh] - b[bestCh]); + const mid = Math.floor(bucket.pixels.length / 2); + buckets.splice( + bestIdx, + 1, + { pixels: bucket.pixels.slice(0, mid) }, + { pixels: bucket.pixels.slice(mid) }, + ); } - return results.map(({ r, g, b }) => { - const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; - return hex; - }); + // Average each bucket to get representative colors, sorted by population + return buckets + .filter((b) => b.pixels.length > 0) + .map((b) => { + let rSum = 0; + let gSum = 0; + let bSum = 0; + for (const px of b.pixels) { + rSum += px[0]; + gSum += px[1]; + bSum += px[2]; + } + const n = b.pixels.length; + return { + r: Math.round(rSum / n), + g: Math.round(gSum / n), + b: Math.round(bSum / n), + count: n, + }; + }) + .sort((a, b) => b.count - a.count); +} + +/** + * Extract dominant colors via median-cut quantization. + */ +function extractColors( + pixels: Buffer, + channelCount: number, + maxColors: number, + fmt: "hex" | "rgb" | "hsl", +): { colors: string[]; hex: string[] } { + const pxArray: Array<[number, number, number]> = []; + for (let i = 0; i < pixels.length; i += channelCount) { + pxArray.push([pixels[i], pixels[i + 1], pixels[i + 2]]); + } + + const representatives = medianCut(pxArray, maxColors); + + return { + colors: representatives.map((c) => formatColor(c.r, c.g, c.b, fmt)), + hex: representatives.map((c) => rgbToHex(c.r, c.g, c.b)), + }; } export function registerColorPalette(app: FastifyInstance) { app.post("/api/v1/tools/color-palette", async (request, reply) => { let fileBuffer: Buffer | null = null; let filename = "image"; + let rawSettings: string | undefined; try { const parts = request.parts(); @@ -59,6 +166,8 @@ export function registerColorPalette(app: FastifyInstance) { } fileBuffer = Buffer.concat(chunks); filename = sanitizeFilename(part.filename ?? "image"); + } else if (part.fieldname === "settings") { + rawSettings = part.value as string; } } } catch (err) { @@ -72,6 +181,18 @@ export function registerColorPalette(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } + // Parse and validate settings + let parsed: { count: number; format: "hex" | "rgb" | "hsl" }; + try { + const json = rawSettings ? JSON.parse(rawSettings) : {}; + parsed = settingsSchema.parse(json); + } catch (err) { + return reply.status(400).send({ + error: "Invalid settings", + details: err instanceof Error ? err.message : String(err), + }); + } + try { const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { @@ -113,18 +234,19 @@ export function registerColorPalette(app: FastifyInstance) { } } - // Resize to small image for analysis + // Resize to small image for analysis (100x100 for better sampling) const raw = await sharp(fileBuffer) - .resize(50, 50, { fit: "fill" }) + .resize(100, 100, { fit: "fill" }) .removeAlpha() .raw() .toBuffer(); - const colors = extractColors(raw, 3, 8); + const { colors, hex } = extractColors(raw, 3, parsed.count, parsed.format); return reply.send({ filename, colors, + hex, count: colors.length, }); } catch (err) { diff --git a/apps/api/src/routes/tools/compress-pdf.ts b/apps/api/src/routes/tools/compress-pdf.ts index 661867b1..6404408f 100644 --- a/apps/api/src/routes/tools/compress-pdf.ts +++ b/apps/api/src/routes/tools/compress-pdf.ts @@ -1,14 +1,24 @@ -import { writeFile } from "node:fs/promises"; +import { copyFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { gsCompressPdf } from "@snapotter/doc-engine"; +import { gsCompressPdfQuality } from "@snapotter/doc-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { createToolRoute } from "../tool-factory.js"; +// Mirrors the image "compress" tool: compress by a quality slider or to a +// target file size. For PDFs the size lever is image downsampling resolution +// (DPI), so quality 1..100 maps onto a DPI range and target-size binary- +// searches that DPI. const settingsSchema = z.object({ - preset: z.enum(["screen", "ebook", "printer"]).default("ebook"), + mode: z.enum(["quality", "targetSize"]).default("quality"), + quality: z.number().int().min(1).max(100).optional(), + targetSizeKb: z.number().positive().optional(), }); +const MIN_DPI = 20; +const MAX_DPI = 300; +const qualityToDpi = (q: number) => Math.round(MIN_DPI + ((q - 1) / 99) * (MAX_DPI - MIN_DPI)); + export function registerCompressPdf(app: FastifyInstance) { createToolRoute(app, { toolId: "compress-pdf", @@ -22,12 +32,41 @@ export function registerCompressPdf(app: FastifyInstance) { const base = input.filename.replace(/\.[^.]+$/, ""); const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`); await writeFile(inPath, input.buffer); - const outPath = join(ctx.scratchDir, `${base}_compressed.pdf`); - ctx.report(10, "Compressing"); - await gsCompressPdf(inPath, outPath, settings.preset); - ctx.report(90, "Done"); + if (settings.mode === "targetSize" && settings.targetSizeKb) { + // Binary-search the DPI for the highest quality that still fits the + // target. Output size is monotonic in DPI, so the search converges. + const targetBytes = settings.targetSizeKb * 1024; + let lo = MIN_DPI; + let hi = MAX_DPI; + let bestPath: string | null = null; + for (let i = 0; i < 6 && lo <= hi; i++) { + const dpi = Math.round((lo + hi) / 2); + const candidate = join(ctx.scratchDir, `cand-${dpi}.pdf`); + ctx.report(10 + i * 13, "Compressing"); + await gsCompressPdfQuality(inPath, candidate, dpi); + const size = (await stat(candidate)).size; + if (size <= targetBytes) { + bestPath = candidate; + lo = dpi + 1; // fits: try higher quality + } else { + hi = dpi - 1; // too big: compress harder + } + } + if (!bestPath) { + // Target unreachable (e.g. a text-only PDF below the floor); fall + // back to the most aggressive compression we can do. + bestPath = join(ctx.scratchDir, "cand-min.pdf"); + await gsCompressPdfQuality(inPath, bestPath, MIN_DPI); + } + await copyFile(bestPath, outPath); + } else { + ctx.report(10, "Compressing"); + await gsCompressPdfQuality(inPath, outPath, qualityToDpi(settings.quality ?? 75)); + } + + ctx.report(95, "Done"); return { scratchPath: outPath, filename: `${base}_compressed.pdf`, diff --git a/apps/api/src/routes/tools/create-zip.ts b/apps/api/src/routes/tools/create-zip.ts index 2fda3b2f..a8ee742f 100644 --- a/apps/api/src/routes/tools/create-zip.ts +++ b/apps/api/src/routes/tools/create-zip.ts @@ -12,6 +12,7 @@ export function registerCreateZip(app: FastifyInstance) { createToolRoute(app, { toolId: "create-zip", maxInputs: 50, + minInputs: 2, settingsSchema, process: async () => { throw new Error("create-zip is v2-only"); @@ -21,20 +22,22 @@ export function registerCreateZip(app: FastifyInstance) { throw new InputValidationError("Zipping needs at least two files"); } - // Deduplicate filenames: name-1.ext, name-2.ext on collision - const usedNames = new Map(); + // Deduplicate output names: append -1, -2, ... until unique. Checking the + // generated name (not just the input name) avoids collisions when an input + // is literally named like a generated one (e.g. file.txt, file.txt, file-1.txt). + const usedNames = new Set(); const entryNames: string[] = []; for (const input of ctx.inputs) { const ext = extname(input.filename); const base = input.filename.slice(0, input.filename.length - ext.length) || "file"; - const key = input.filename.toLowerCase(); - const count = usedNames.get(key) ?? 0; - if (count === 0) { - entryNames.push(input.filename); - } else { - entryNames.push(`${base}-${count}${ext}`); + let name = input.filename; + let n = 1; + while (usedNames.has(name.toLowerCase())) { + name = `${base}-${n}${ext}`; + n++; } - usedNames.set(key, count + 1); + usedNames.add(name.toLowerCase()); + entryNames.push(name); } const zipPath = join(ctx.scratchDir, "archive.zip"); diff --git a/apps/api/src/routes/tools/crop-video.ts b/apps/api/src/routes/tools/crop-video.ts index 4804c73e..6a4ea86b 100644 --- a/apps/api/src/routes/tools/crop-video.ts +++ b/apps/api/src/routes/tools/crop-video.ts @@ -1,8 +1,13 @@ import { extname, join } from "node:path"; -import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +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 { + runFfmpegWithProgress, + stageMediaInputs, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { InputValidationError } from "../../modality/contract.js"; import { createToolRoute } from "../tool-factory.js"; @@ -47,14 +52,7 @@ export function registerCropVideo(app: FastifyInstance) { inPath, "-vf", `crop=${settings.width}:${settings.height}:${settings.x}:${settings.y}`, - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), "-c:a", "copy", outPath, diff --git a/apps/api/src/routes/tools/csv-excel.ts b/apps/api/src/routes/tools/csv-excel.ts index 264b2454..0919b545 100644 --- a/apps/api/src/routes/tools/csv-excel.ts +++ b/apps/api/src/routes/tools/csv-excel.ts @@ -39,7 +39,9 @@ export function registerCsvExcel(app: FastifyInstance) { ws.eachRow((row) => { const cells: string[] = []; row.eachCell({ includeEmpty: true }, (cell) => { - cells.push(cell.text); + // cell.text renders dates via Date.toString() (timezone-dependent and + // not round-trippable); emit ISO 8601 for Date values instead. + cells.push(cell.value instanceof Date ? cell.value.toISOString() : cell.text); }); rows.push(cells); }); diff --git a/apps/api/src/routes/tools/csv-json.ts b/apps/api/src/routes/tools/csv-json.ts index 634de9c4..819b0840 100644 --- a/apps/api/src/routes/tools/csv-json.ts +++ b/apps/api/src/routes/tools/csv-json.ts @@ -21,11 +21,31 @@ export function registerCsvJson(app: FastifyInstance) { const lower = input.filename.toLowerCase(); if (lower.endsWith(".json")) { - const data: unknown = JSON.parse(input.buffer.toString("utf8")); + let data: unknown; + try { + data = JSON.parse(input.buffer.toString("utf8")); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Not valid JSON: ${msg.split("\n")[0]}`); + } if (!Array.isArray(data)) { throw new Error("JSON input must be an array of objects to convert to CSV"); } - const csv = Papa.unparse(data as Record[]); + if (data.some((r) => r === null || typeof r !== "object" || Array.isArray(r))) { + throw new Error("JSON array elements must be objects to convert to CSV"); + } + // Flatten nested objects/arrays to JSON strings (Papa would otherwise emit + // "[object Object]"), and pass the union of all keys so columns appearing + // only in later rows are not dropped. + const flattened = (data as Record[]).map((row) => { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) { + out[k] = v !== null && typeof v === "object" ? JSON.stringify(v) : v; + } + return out; + }); + const columns = Array.from(new Set(flattened.flatMap((row) => Object.keys(row)))); + const csv = Papa.unparse(flattened, { columns }); return { buffer: Buffer.from(csv, "utf8"), filename: `${base}.csv`, diff --git a/apps/api/src/routes/tools/duotone.ts b/apps/api/src/routes/tools/duotone.ts index 2524d104..7b0f779b 100644 --- a/apps/api/src/routes/tools/duotone.ts +++ b/apps/api/src/routes/tools/duotone.ts @@ -9,6 +9,7 @@ const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/); const settingsSchema = z.object({ shadow: hexColor.default("#1e3a8a"), highlight: hexColor.default("#fbbf24"), + intensity: z.number().int().min(0).max(100).default(100), }); function parseHex(hex: string) { @@ -26,6 +27,7 @@ export function registerDuotone(app: FastifyInstance) { process: async (inputBuffer, settings, filename) => { const a = parseHex(settings.shadow); const b = parseHex(settings.highlight); + const k = settings.intensity / 100; // Duotone math: output = shadow + (highlight - shadow) * luminance // .linear(multipliers, offsets) with per-channel arrays @@ -40,7 +42,34 @@ export function registerDuotone(app: FastifyInstance) { .toColourspace("srgb") .toBuffer(); - const buf = await sharp(grayBuf).linear(multipliers, offsets).toBuffer(); + let buf = await sharp(grayBuf).linear(multipliers, offsets).toBuffer(); + + // Blend duotone with original when intensity < 100. Both buffers are read + // through the same removeAlpha + sRGB pipeline so they share channel count + // and length, and the blended raw buffer is re-encoded to PNG so the final + // toFormat() step can decode it again. + if (k < 1) { + const origRaw = await sharp(inputBuffer) + .removeAlpha() + .toColourspace("srgb") + .raw() + .toBuffer({ resolveWithObject: true }); + const duo = await sharp(buf).removeAlpha().toColourspace("srgb").raw().toBuffer(); + const pixels = origRaw.data; + const blended = Buffer.alloc(pixels.length); + for (let i = 0; i < pixels.length; i++) { + blended[i] = Math.round(pixels[i] * (1 - k) + duo[i] * k); + } + buf = await sharp(blended, { + raw: { + width: origRaw.info.width, + height: origRaw.info.height, + channels: origRaw.info.channels as 1 | 2 | 3 | 4, + }, + }) + .png() + .toBuffer(); + } const outputFormat = await resolveOutputFormat(inputBuffer, filename); const buffer = await sharp(buf) diff --git a/apps/api/src/routes/tools/extract-zip.ts b/apps/api/src/routes/tools/extract-zip.ts index 4dd8cdb1..08c5c53c 100644 --- a/apps/api/src/routes/tools/extract-zip.ts +++ b/apps/api/src/routes/tools/extract-zip.ts @@ -53,22 +53,22 @@ function readEntryBuffer(zipfile: ZipFile, entry: Entry): Promise { }); } -/** Deduplicate basenames: name-1.ext, name-2.ext on collision. */ +/** Deduplicate basenames: name-1.ext, name-2.ext until each output is unique. */ function deduplicateNames(names: string[]): string[] { - const usedNames = new Map(); + const usedNames = new Set(); const result: string[] = []; for (const raw of names) { const name = basename(raw); const ext = extname(name); const base = name.slice(0, name.length - ext.length) || "file"; - const key = name.toLowerCase(); - const count = usedNames.get(key) ?? 0; - if (count === 0) { - result.push(name); - } else { - result.push(`${base}-${count}${ext}`); + let candidate = name; + let n = 1; + while (usedNames.has(candidate.toLowerCase())) { + candidate = `${base}-${n}${ext}`; + n++; } - usedNames.set(key, count + 1); + usedNames.add(candidate.toLowerCase()); + result.push(candidate); } return result; } @@ -77,6 +77,39 @@ export function registerExtractZip(app: FastifyInstance) { createToolRoute(app, { toolId: "extract-zip", settingsSchema, + // Reject path-traversal / absolute-path entries pre-enqueue with a clean 400. + // (yauzl also blocks them, but only as a generic worker 422; the processV2 + // guards below remain as defense-in-depth for the pipeline/batch path.) + preValidate: async ({ inputs }) => { + const buf = inputs[0]?.buffer; + if (!buf) return; + let entries: Entry[]; + try { + entries = await collectEntries(await openZipBuffer(buf)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/relative path|absolute path/i.test(msg)) { + throw new InputValidationError( + "This archive contains an unsafe entry path (path traversal or absolute) and was rejected", + ); + } + throw new InputValidationError( + "Could not read the archive; it may be corrupt or not a valid .zip", + ); + } + for (const e of entries) { + const name = e.fileName; + if ( + name.startsWith("/") || + name.startsWith("\\") || + name.split(/[/\\]/).some((s) => s === "..") + ) { + throw new InputValidationError( + "This archive contains an unsafe entry path (path traversal or absolute) and was rejected", + ); + } + } + }, process: async () => { throw new Error("extract-zip is v2-only"); }, @@ -101,6 +134,10 @@ export function registerExtractZip(app: FastifyInstance) { fileEntries.push(entry); } + if (fileEntries.length === 0) { + throw new InputValidationError("No extractable files found in the archive"); + } + // Guard: entry count if (fileEntries.length > MAX_ENTRIES) { throw new InputValidationError("Too many entries"); diff --git a/apps/api/src/routes/tools/favicon.ts b/apps/api/src/routes/tools/favicon.ts index dfaa9b8d..4f465e2e 100644 --- a/apps/api/src/routes/tools/favicon.ts +++ b/apps/api/src/routes/tools/favicon.ts @@ -16,7 +16,21 @@ import { encodeMultiIco, hasMagick } from "../../lib/format-encoders.js"; import { decodeHeic } from "../../lib/heic-converter.js"; import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; -const settingsSchema = z.object({}).passthrough(); +const settingsSchema = z.object({ + background: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .optional(), + padding: z.number().int().min(0).max(40).default(0), + radius: z.number().int().min(0).max(50).default(0), + sizes: z.array(z.number().int()).optional(), + themeColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default("#ffffff"), +}); + +type FaviconSettings = z.infer; const FAVICON_SIZES = [ { name: "favicon-16x16.png", size: 16, format: "png" as const }, @@ -27,6 +41,38 @@ const FAVICON_SIZES = [ { name: "android-chrome-512x512.png", size: 512, format: "png" as const }, ]; +/** Build a single icon at the given pixel size with styling applied. */ +async function buildIcon(source: Buffer, size: number, settings: FaviconSettings): Promise { + const inset = settings.padding > 0 ? Math.round((size * settings.padding) / 100) : 0; + const contentSize = Math.max(1, size - 2 * inset); + + let pipeline = sharp(source).resize(contentSize, contentSize, { fit: "cover" }); + + if (settings.background) { + pipeline = pipeline.flatten({ background: settings.background }); + } + + if (inset > 0) { + pipeline = pipeline.extend({ + top: inset, + bottom: inset, + left: inset, + right: inset, + background: settings.background || { r: 0, g: 0, b: 0, alpha: 0 }, + }); + } + + if (settings.radius > 0) { + const rx = Math.round((size * settings.radius) / 100); + const mask = Buffer.from( + ``, + ); + pipeline = pipeline.ensureAlpha().composite([{ input: mask, blend: "dest-in" }]); + } + + return pipeline.png().toBuffer(); +} + interface UploadedFile { buffer: Buffer; filename: string; @@ -135,6 +181,8 @@ export function registerFavicon(app: FastifyInstance) { }); } + let settings: FaviconSettings = { padding: 0, radius: 0, themeColor: "#ffffff" }; + if (settingsRaw) { try { const parsed = JSON.parse(settingsRaw); @@ -144,6 +192,7 @@ export function registerFavicon(app: FastifyInstance) { .status(400) .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); } + settings = result.data; } catch { return reply.status(400).send({ error: "Settings must be valid JSON" }); } @@ -152,6 +201,9 @@ export function registerFavicon(app: FastifyInstance) { try { const jobId = randomUUID(); const isSingleFile = decodedFiles.length === 1; + const filteredSizes = settings.sizes + ? FAVICON_SIZES.filter((s) => settings.sizes?.includes(s.size)) + : FAVICON_SIZES; reply.hijack(); reply.raw.writeHead(200, { @@ -168,11 +220,8 @@ export function registerFavicon(app: FastifyInstance) { const stem = sanitizeFilename(file.filename).replace(/\.[^.]+$/, ""); const prefix = isSingleFile ? "" : `${stem}/`; - for (const icon of FAVICON_SIZES) { - const buffer = await sharp(file.buffer) - .resize(icon.size, icon.size, { fit: "cover" }) - .png() - .toBuffer(); + for (const icon of filteredSizes) { + const buffer = await buildIcon(file.buffer, icon.size, settings); archive.append(buffer, { name: `${prefix}${icon.name}` }); } @@ -187,10 +236,7 @@ export function registerFavicon(app: FastifyInstance) { try { for (const sz of icoSizes) { const pngPath = join(tmpdir(), `favicon-${icoId}-${sz}.png`); - const buf = await sharp(file.buffer) - .resize(sz, sz, { fit: "cover" }) - .png() - .toBuffer(); + const buf = await buildIcon(file.buffer, sz, settings); await writeFile(pngPath, buf); icoPaths.push(pngPath); } @@ -203,31 +249,44 @@ export function registerFavicon(app: FastifyInstance) { } } } else { - const ico32 = await sharp(file.buffer).resize(32, 32, { fit: "cover" }).png().toBuffer(); + const ico32 = await buildIcon(file.buffer, 32, settings); archive.append(ico32, { name: `${prefix}favicon.ico` }); } + const manifestIcons = filteredSizes + .filter((s) => s.size === 192 || s.size === 512) + .map((s) => ({ + src: `/${s.name}`, + sizes: `${s.size}x${s.size}`, + type: "image/png", + })); const manifest = { name: stem, short_name: stem, - icons: [ - { src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" }, - { src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" }, - ], - theme_color: "#ffffff", - background_color: "#ffffff", + icons: manifestIcons, + theme_color: settings.themeColor, + background_color: settings.themeColor, display: "standalone", }; archive.append(JSON.stringify(manifest, null, 2), { name: `${prefix}manifest.json` }); - const htmlSnippet = ` - - - - - -`; - archive.append(htmlSnippet, { name: `${prefix}favicon-snippet.html` }); + const snippetLines = [""]; + for (const s of filteredSizes) { + if (s.name.startsWith("android-chrome")) continue; + if (s.name === "apple-touch-icon.png") { + snippetLines.push( + ``, + ); + } else { + snippetLines.push( + ``, + ); + } + } + snippetLines.push(''); + archive.append(`${snippetLines.join("\n")}\n`, { + name: `${prefix}favicon-snippet.html`, + }); } if (skippedFiles.length > 0) { diff --git a/apps/api/src/routes/tools/gif-webp.ts b/apps/api/src/routes/tools/gif-webp.ts index 8bbf6c9e..9794d99b 100644 --- a/apps/api/src/routes/tools/gif-webp.ts +++ b/apps/api/src/routes/tools/gif-webp.ts @@ -5,13 +5,17 @@ import { z } from "zod"; import { InputValidationError } from "../../modality/contract.js"; import { createToolRoute } from "../tool-factory.js"; -const settingsSchema = z.object({}); +const settingsSchema = z.object({ + quality: z.number().int().min(1).max(100).default(80), + lossless: z.boolean().default(false), + resizePercent: z.number().int().min(10).max(100).default(100), +}); export function registerGifWebp(app: FastifyInstance) { createToolRoute(app, { toolId: "gif-webp", settingsSchema, - process: async (inputBuffer, _settings, filename) => { + process: async (inputBuffer, settings, filename) => { const ext = extname(filename).toLowerCase(); // Route-level extension guard: image modality has no 415 gate @@ -19,10 +23,23 @@ export function registerGifWebp(app: FastifyInstance) { throw new InputValidationError("Only GIF and WebP inputs are supported"); } + let pipeline = sharp(inputBuffer, { animated: true }); + + // Apply resize when below 100% + if (settings.resizePercent < 100) { + const meta = await sharp(inputBuffer, { animated: true }).metadata(); + const origW = meta.width ?? 1; + const target = Math.round(origW * (settings.resizePercent / 100)); + pipeline = pipeline.resize(target); + } + + const base = filename.replace(/\.[^.]+$/, ""); + if (ext === ".gif") { // GIF -> WebP (preserving animation) - const buffer = await sharp(inputBuffer, { animated: true }).webp().toBuffer(); - const base = filename.replace(/\.[^.]+$/, ""); + const buffer = await pipeline + .webp({ quality: settings.quality, lossless: settings.lossless }) + .toBuffer(); return { buffer, filename: `${base}.webp`, @@ -31,8 +48,8 @@ export function registerGifWebp(app: FastifyInstance) { } // WebP -> GIF (preserving animation) - const buffer = await sharp(inputBuffer, { animated: true }).gif().toBuffer(); - const base = filename.replace(/\.[^.]+$/, ""); + // Note: quality and lossless are WebP-only; GIF uses a fixed palette. + const buffer = await pipeline.gif().toBuffer(); return { buffer, filename: `${base}.gif`, diff --git a/apps/api/src/routes/tools/histogram.ts b/apps/api/src/routes/tools/histogram.ts index 79bfea3a..d07fca09 100644 --- a/apps/api/src/routes/tools/histogram.ts +++ b/apps/api/src/routes/tools/histogram.ts @@ -3,7 +3,30 @@ import sharp from "sharp"; import { z } from "zod"; import { createToolRoute } from "../tool-factory.js"; -const settingsSchema = z.object({}); +const settingsSchema = z + .object({ + scale: z.enum(["linear", "log"]).default("linear"), + }) + .passthrough(); + +function medianFromBins(bins: Uint32Array, total: number): number { + const half = total / 2; + let cumulative = 0; + for (let i = 0; i < 256; i++) { + cumulative += bins[i]; + if (cumulative >= half) return i; + } + return 255; +} + +function stdevFromBins(bins: Uint32Array, mean: number, total: number): number { + let sumSqDiff = 0; + for (let i = 0; i < 256; i++) { + const diff = i - mean; + sumSqDiff += diff * diff * bins[i]; + } + return Math.round(Math.sqrt(sumSqDiff / total) * 100) / 100; +} export function registerHistogram(app: FastifyInstance) { createToolRoute(app, { @@ -22,14 +45,16 @@ export function registerHistogram(app: FastifyInstance) { .raw() .toBuffer({ resolveWithObject: true }); - // Build 256-bin histograms per channel in a single pass + // Build 256-bin histograms per channel + luminance in a single pass const rBins = new Uint32Array(256); const gBins = new Uint32Array(256); const bBins = new Uint32Array(256); + const lumBins = new Uint32Array(256); let rSum = 0; let gSum = 0; let bSum = 0; + let lumSum = 0; const pixelCount = data.length / 3; for (let i = 0; i < data.length; i += 3) { @@ -42,9 +67,12 @@ export function registerHistogram(app: FastifyInstance) { rSum += r; gSum += g; bSum += b; + const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + lumBins[lum]++; + lumSum += lum; } - // Find max bin value for normalization + // Find max bin value for normalization (RGB only for the PNG) let maxBin = 0; let rMax = 0; let gMax = 0; @@ -58,6 +86,35 @@ export function registerHistogram(app: FastifyInstance) { if (bBins[i] > bMax) bMax = bBins[i]; } + // Per-channel statistics + const rMean = Math.round(rSum / pixelCount); + const gMean = Math.round(gSum / pixelCount); + const bMean = Math.round(bSum / pixelCount); + const lumMean = Math.round(lumSum / pixelCount); + + const stats = { + r: { + mean: rMean, + median: medianFromBins(rBins, pixelCount), + stdev: stdevFromBins(rBins, rSum / pixelCount, pixelCount), + }, + g: { + mean: gMean, + median: medianFromBins(gBins, pixelCount), + stdev: stdevFromBins(gBins, gSum / pixelCount, pixelCount), + }, + b: { + mean: bMean, + median: medianFromBins(bBins, pixelCount), + stdev: stdevFromBins(bBins, bSum / pixelCount, pixelCount), + }, + lum: { + mean: lumMean, + median: medianFromBins(lumBins, pixelCount), + stdev: stdevFromBins(lumBins, lumSum / pixelCount, pixelCount), + }, + }; + // Render a 512x320 SVG with three semi-transparent polylines const svgW = 512; const svgH = 320; @@ -92,16 +149,16 @@ export function registerHistogram(app: FastifyInstance) { filename: `${base}_histogram.png`, contentType: "image/png", resultPayload: { - mean: { - r: Math.round(rSum / pixelCount), - g: Math.round(gSum / pixelCount), - b: Math.round(bSum / pixelCount), - }, - max: { - r: rMax, - g: gMax, - b: bMax, + bins: { + r: Array.from(rBins), + g: Array.from(gBins), + b: Array.from(bBins), + lum: Array.from(lumBins), }, + stats, + // Backward-compat fields + mean: { r: rMean, g: gMean, b: bMean }, + max: { r: rMax, g: gMax, b: bMax }, }, }; }, diff --git a/apps/api/src/routes/tools/image-pad.ts b/apps/api/src/routes/tools/image-pad.ts index 00fe953b..cec67d8e 100644 --- a/apps/api/src/routes/tools/image-pad.ts +++ b/apps/api/src/routes/tools/image-pad.ts @@ -5,11 +5,15 @@ import { resolveOutputFormat } from "../../lib/output-format.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ - target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("1:1"), + target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4", "custom"]).default("1:1"), + ratioW: z.number().int().min(1).max(100).default(1), + ratioH: z.number().int().min(1).max(100).default(1), + background: z.enum(["color", "transparent", "blur"]).default("color"), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/) .default("#ffffff"), + padding: z.number().int().min(0).max(50).default(0), }); /** Compute canvas dimensions for the given target aspect ratio. */ @@ -51,25 +55,72 @@ export function registerImagePad(app: FastifyInstance) { const w = meta.width ?? 1; const h = meta.height ?? 1; - const { cw, ch } = canvasFor(w, h, settings.target); - const c = parseHex(settings.color); + // Resolve target ratio -- custom uses ratioW:ratioH + const ratioStr = + settings.target === "custom" ? `${settings.ratioW}:${settings.ratioH}` : settings.target; - const padTop = Math.floor((ch - h) / 2); - const padBottom = ch - h - padTop; - const padLeft = Math.floor((cw - w) / 2); - const padRight = cw - w - padLeft; + const { cw, ch } = canvasFor(w, h, ratioStr); - const buf = await sharp(inputBuffer) - .extend({ - top: padTop, - bottom: padBottom, - left: padLeft, - right: padRight, - background: { r: c.r, g: c.g, b: c.b, alpha: 1 }, - }) - .toBuffer(); + // Extra uniform padding margin (% of the canvas larger side) + const margin = + settings.padding > 0 ? Math.round((Math.max(cw, ch) * settings.padding) / 100) : 0; + const finalW = cw + margin * 2; + const finalH = ch + margin * 2; + + const padTop = Math.floor((finalH - h) / 2); + const padBottom = finalH - h - padTop; + const padLeft = Math.floor((finalW - w) / 2); + const padRight = finalW - w - padLeft; + + let buf: Buffer; + + if (settings.background === "blur") { + // Instagram-style: blurred cover fill + sharp original composited on top + const blurred = await sharp(inputBuffer) + .resize(finalW, finalH, { fit: "cover" }) + .blur(20) + .png() + .toBuffer(); + buf = await sharp(blurred) + .composite([{ input: inputBuffer, top: padTop, left: padLeft }]) + .png() + .toBuffer(); + } else if (settings.background === "transparent") { + buf = await sharp(inputBuffer) + .ensureAlpha() + .extend({ + top: padTop, + bottom: padBottom, + left: padLeft, + right: padRight, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer(); + } else { + const c = parseHex(settings.color); + buf = await sharp(inputBuffer) + .extend({ + top: padTop, + bottom: padBottom, + left: padLeft, + right: padRight, + background: { r: c.r, g: c.g, b: c.b, alpha: 1 }, + }) + .toBuffer(); + } + + // Transparent forces PNG to preserve alpha; otherwise detect from input + const forcePng = settings.background === "transparent"; + const outputFormat = forcePng + ? { + format: "png" as const, + extension: "png", + contentType: "image/png", + quality: 95, + } + : await resolveOutputFormat(inputBuffer, filename); - const outputFormat = await resolveOutputFormat(inputBuffer, filename); const buffer = await sharp(buf) .toFormat(outputFormat.format, { quality: outputFormat.quality }) .toBuffer(); diff --git a/apps/api/src/routes/tools/json-xml.ts b/apps/api/src/routes/tools/json-xml.ts index 957b5a69..e8a929ab 100644 --- a/apps/api/src/routes/tools/json-xml.ts +++ b/apps/api/src/routes/tools/json-xml.ts @@ -1,6 +1,7 @@ import { XMLBuilder, XMLParser } from "fast-xml-parser"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { InputValidationError } from "../../modality/contract.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -34,14 +35,22 @@ export function registerJsonXml(app: FastifyInstance) { } // json -> xml - const data: unknown = JSON.parse(text); - // Wrap in a root element when the top level is an array or has - // multiple keys, so the XML is well-formed with a single root. + let data: unknown; + try { + data = JSON.parse(text); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new InputValidationError(`Not valid JSON: ${msg.split("\n")[0]}`); + } + // The builder needs an object/array root; primitives (null, string, number, + // boolean) crash builder.build or produce per-character garbage XML. + if (data === null || typeof data !== "object") { + throw new InputValidationError("JSON must be an object or array to convert to XML"); + } + // Wrap in a root element when the top level is an array or has multiple + // keys, so the XML is well-formed with a single root. const wrapped = - Array.isArray(data) || - (typeof data === "object" && data !== null && Object.keys(data).length !== 1) - ? { root: data } - : data; + Array.isArray(data) || Object.keys(data).length !== 1 ? { root: data } : data; const builder = new XMLBuilder({ format: settings.pretty, ignoreAttributes: false }); const xml = builder.build(wrapped) as string; return { diff --git a/apps/api/src/routes/tools/lqip-placeholder.ts b/apps/api/src/routes/tools/lqip-placeholder.ts index 67b124e5..12d28b4f 100644 --- a/apps/api/src/routes/tools/lqip-placeholder.ts +++ b/apps/api/src/routes/tools/lqip-placeholder.ts @@ -6,8 +6,29 @@ import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ width: z.number().int().min(4).max(64).default(16), blur: z.number().min(0).max(20).default(2), + strategy: z.enum(["blur", "pixelate", "solid"]).default("blur"), + format: z.enum(["webp", "png", "jpeg"]).default("webp"), + quality: z.number().int().min(1).max(100).default(50), }); +const MIME: Record = { + webp: "image/webp", + png: "image/png", + jpeg: "image/jpeg", +}; + +const EXT: Record = { + webp: ".webp", + png: ".png", + jpeg: ".jpg", +}; + +function encode(pipeline: sharp.Sharp, fmt: string, q: number): sharp.Sharp { + if (fmt === "jpeg") return pipeline.jpeg({ quality: q }); + if (fmt === "png") return pipeline.png(); + return pipeline.webp({ quality: q }); +} + export function registerLqipPlaceholder(app: FastifyInstance) { createToolRoute(app, { toolId: "lqip-placeholder", @@ -20,27 +41,47 @@ export function registerLqipPlaceholder(app: FastifyInstance) { const inputBuffer = ctx.inputs[0].buffer; const filename = ctx.inputs[0].filename; - let pipeline = sharp(inputBuffer).resize(settings.width); + let pipeline: sharp.Sharp; - if (settings.blur > 0) { - pipeline = pipeline.blur(settings.blur); + if (settings.strategy === "solid") { + const pixel = await sharp(inputBuffer).resize(1, 1).raw().toBuffer(); + pipeline = sharp({ + create: { + width: settings.width, + height: settings.width, + channels: 3, + background: { r: pixel[0], g: pixel[1], b: pixel[2] }, + }, + }); + } else if (settings.strategy === "pixelate") { + pipeline = sharp(inputBuffer).resize(settings.width, null, { + kernel: sharp.kernel.nearest, + }); + } else { + pipeline = sharp(inputBuffer).resize(settings.width); + if (settings.blur > 0) { + pipeline = pipeline.blur(settings.blur); + } } - const buffer = await pipeline.webp({ quality: 50 }).toBuffer(); - + const buffer = await encode(pipeline, settings.format, settings.quality).toBuffer(); const meta = await sharp(buffer).metadata(); - const dataUri = `data:image/webp;base64,${buffer.toString("base64")}`; + const mime = MIME[settings.format]; + const dataUri = `data:${mime};base64,${buffer.toString("base64")}`; const base = filename.replace(/\.[^.]+$/, ""); return { buffer, - filename: `${base}_lqip.webp`, - contentType: "image/webp", + filename: `${base}_lqip${EXT[settings.format]}`, + contentType: mime, resultPayload: { dataUri, width: meta.width ?? settings.width, height: meta.height ?? 0, bytes: buffer.length, + strategy: settings.strategy, + html: ``, + css: `background-image:url('${dataUri}');background-size:cover;background-position:center;`, }, }; }, diff --git a/apps/api/src/routes/tools/merge-csvs.ts b/apps/api/src/routes/tools/merge-csvs.ts index 6b6b6db1..6e6b2dc7 100644 --- a/apps/api/src/routes/tools/merge-csvs.ts +++ b/apps/api/src/routes/tools/merge-csvs.ts @@ -10,6 +10,7 @@ export function registerMergeCsvs(app: FastifyInstance) { createToolRoute(app, { toolId: "merge-csvs", maxInputs: 20, + minInputs: 2, settingsSchema, process: async () => { throw new Error("merge-csvs is v2-only"); diff --git a/apps/api/src/routes/tools/pixelate.ts b/apps/api/src/routes/tools/pixelate.ts index 3936683c..d3c0d589 100644 --- a/apps/api/src/routes/tools/pixelate.ts +++ b/apps/api/src/routes/tools/pixelate.ts @@ -30,12 +30,19 @@ export function registerPixelate(app: FastifyInstance) { let buf: Buffer; if (settings.region) { - const r = settings.region; - // Validate region bounds - if (r.left + r.width > w || r.top + r.height > h) { + // Reject if origin is completely outside image bounds + if (settings.region.left >= w || settings.region.top >= h) { throw new InputValidationError("Region exceeds image bounds"); } + // Clamp region dimensions to image edges (handles rounding from normalized coords) + const r = { + left: settings.region.left, + top: settings.region.top, + width: Math.min(settings.region.width, w - settings.region.left), + height: Math.min(settings.region.height, h - settings.region.top), + }; + // Extract region, pixelate it, composite back const rw = Math.max(1, Math.round(r.width / bs)); const rh = Math.max(1, Math.round(r.height / bs)); diff --git a/apps/api/src/routes/tools/replace-audio.ts b/apps/api/src/routes/tools/replace-audio.ts index e6b884f5..fc939932 100644 --- a/apps/api/src/routes/tools/replace-audio.ts +++ b/apps/api/src/routes/tools/replace-audio.ts @@ -1,8 +1,13 @@ import { basename, extname, join } from "node:path"; -import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +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, +} from "../../lib/media-tool.js"; import { InputValidationError } from "../../modality/contract.js"; import { createToolRoute } from "../tool-factory.js"; @@ -54,8 +59,7 @@ export function registerReplaceAudio(app: FastifyInstance) { "1:a:0", "-c:v", "copy", - "-c:a", - resolveEncoder("aac"), + ...audioEncodeArgsForContainer(ext), "-shortest", outPath, ]; diff --git a/apps/api/src/routes/tools/resize-video.ts b/apps/api/src/routes/tools/resize-video.ts index f25f291b..8144e507 100644 --- a/apps/api/src/routes/tools/resize-video.ts +++ b/apps/api/src/routes/tools/resize-video.ts @@ -1,8 +1,11 @@ import { extname } from "node:path"; -import { resolveEncoder } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { + runMediaTool, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const PRESET_HEIGHTS: Record = { @@ -53,14 +56,7 @@ export function registerResizeVideo(app: FastifyInstance) { inPath, "-vf", `scale=${w}:${h}:flags=lanczos`, - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), "-c:a", "copy", out, diff --git a/apps/api/src/routes/tools/reverse-video.ts b/apps/api/src/routes/tools/reverse-video.ts index 9862b725..1f8cf62b 100644 --- a/apps/api/src/routes/tools/reverse-video.ts +++ b/apps/api/src/routes/tools/reverse-video.ts @@ -1,8 +1,14 @@ import { extname, join } from "node:path"; -import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +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"; @@ -41,16 +47,8 @@ export function registerReverseVideo(app: FastifyInstance) { "reverse", "-af", "areverse", - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", - "-c:a", - resolveEncoder("aac"), + ...videoEncodeArgsForContainer(origExt), + ...audioEncodeArgsForContainer(origExt), outPath, ]; } else { @@ -60,14 +58,7 @@ export function registerReverseVideo(app: FastifyInstance) { "-vf", "reverse", "-an", - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), outPath, ]; } diff --git a/apps/api/src/routes/tools/rotate-video.ts b/apps/api/src/routes/tools/rotate-video.ts index 1b77c47a..4bd8f9e3 100644 --- a/apps/api/src/routes/tools/rotate-video.ts +++ b/apps/api/src/routes/tools/rotate-video.ts @@ -1,8 +1,11 @@ import { extname } from "node:path"; -import { resolveEncoder } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { + runMediaTool, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const VF_MAP: Record = { @@ -36,14 +39,7 @@ export function registerRotateVideo(app: FastifyInstance) { inPath, "-vf", VF_MAP[settings.transform], - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), "-c:a", "copy", out, diff --git a/apps/api/src/routes/tools/split-csv.ts b/apps/api/src/routes/tools/split-csv.ts index 94656c88..72988256 100644 --- a/apps/api/src/routes/tools/split-csv.ts +++ b/apps/api/src/routes/tools/split-csv.ts @@ -36,8 +36,13 @@ export function registerSplitCsv(app: FastifyInstance) { throw new Error("CSV file is empty"); } - const header = settings.keepHeader ? allRows[0] : null; - const dataRows = settings.keepHeader ? allRows.slice(1) : allRows; + // Always treat row 0 as the header; keepHeader only controls whether it is + // repeated into each part (false = parts contain data rows only). + const header = allRows[0]; + const dataRows = allRows.slice(1); + if (dataRows.length === 0) { + throw new Error("No data rows to split"); + } // Chunk data rows const chunks: string[][][] = []; @@ -48,7 +53,7 @@ export function registerSplitCsv(app: FastifyInstance) { // Write part files to scratch const partPaths: string[] = []; for (let i = 0; i < chunks.length; i++) { - const rows = header ? [header, ...chunks[i]] : chunks[i]; + const rows = settings.keepHeader ? [header, ...chunks[i]] : chunks[i]; const csv = Papa.unparse(rows); const partPath = join(ctx.scratchDir, `part-${i + 1}.csv`); await writeFile(partPath, csv, "utf8"); diff --git a/apps/api/src/routes/tools/sprite-sheet.ts b/apps/api/src/routes/tools/sprite-sheet.ts index 7c7517cd..aaa91d2a 100644 --- a/apps/api/src/routes/tools/sprite-sheet.ts +++ b/apps/api/src/routes/tools/sprite-sheet.ts @@ -11,6 +11,8 @@ const settingsSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6}$/) .default("#ffffff"), + format: z.enum(["png", "webp", "jpeg"]).default("png"), + quality: z.number().int().min(1).max(100).default(90), }); function parseHex(hex: string) { @@ -78,23 +80,47 @@ export function registerSpriteSheet(app: FastifyInstance) { frames.push({ index: i, left, top, width: cellW, height: cellH }); } - const buffer = await sharp({ + let pipeline = sharp({ create: { width: canvasW, height: canvasH, channels: 4, background: { r: bg.r, g: bg.g, b: bg.b, alpha: 1 }, }, - }) - .composite(composites) - .png() - .toBuffer(); + }).composite(composites); + + const fmt = settings.format; + let filename: string; + let contentType: string; + if (fmt === "webp") { + pipeline = pipeline.webp({ quality: settings.quality }); + filename = "sprite.webp"; + contentType = "image/webp"; + } else if (fmt === "jpeg") { + pipeline = pipeline.jpeg({ quality: settings.quality }); + filename = "sprite.jpg"; + contentType = "image/jpeg"; + } else { + pipeline = pipeline.png(); + filename = "sprite.png"; + contentType = "image/png"; + } + + const buffer = await pipeline.toBuffer(); return { buffer, - filename: "sprite.png", - contentType: "image/png", - resultPayload: { frames }, + filename, + contentType, + resultPayload: { + frames, + cols, + rows, + cellWidth: cellW, + cellHeight: cellH, + canvasWidth: canvasW, + canvasHeight: canvasH, + }, }; }, }); diff --git a/apps/api/src/routes/tools/trim-video.ts b/apps/api/src/routes/tools/trim-video.ts index a8d9e357..8e04d07e 100644 --- a/apps/api/src/routes/tools/trim-video.ts +++ b/apps/api/src/routes/tools/trim-video.ts @@ -1,8 +1,12 @@ import { extname } from "node:path"; -import { resolveEncoder } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { + audioEncodeArgsForContainer, + runMediaTool, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z @@ -36,16 +40,8 @@ export function registerTrimVideo(app: FastifyInstance) { String(settings.startS), "-to", String(settings.endS), - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", - "-c:a", - resolveEncoder("aac"), + ...videoEncodeArgsForContainer(origExt), + ...audioEncodeArgsForContainer(origExt), out, ]; } diff --git a/apps/api/src/routes/tools/video-color.ts b/apps/api/src/routes/tools/video-color.ts index 4f51448c..33669328 100644 --- a/apps/api/src/routes/tools/video-color.ts +++ b/apps/api/src/routes/tools/video-color.ts @@ -1,8 +1,11 @@ import { extname } from "node:path"; -import { resolveEncoder } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { + runMediaTool, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -31,14 +34,7 @@ export function registerVideoColor(app: FastifyInstance) { inPath, "-vf", `eq=brightness=${settings.brightness}:contrast=${settings.contrast}:saturation=${settings.saturation}:gamma=${settings.gamma}`, - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), "-c:a", "copy", out, diff --git a/apps/api/src/routes/tools/video-loudnorm.ts b/apps/api/src/routes/tools/video-loudnorm.ts index 94847b4c..f1267880 100644 --- a/apps/api/src/routes/tools/video-loudnorm.ts +++ b/apps/api/src/routes/tools/video-loudnorm.ts @@ -1,8 +1,13 @@ import { extname, join } from "node:path"; -import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +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, +} from "../../lib/media-tool.js"; import { InputValidationError } from "../../modality/contract.js"; import { createToolRoute } from "../tool-factory.js"; @@ -30,17 +35,18 @@ export function registerVideoLoudnorm(app: FastifyInstance) { const outPath = join(ctx.scratchDir, "media", outName); + // loudnorm runs internally at 192 kHz and emits at 192 kHz unless we + // resample back, so restore the source rate to avoid inflating the audio. + const sr = info.streams.find((s) => s.type === "audio")?.sampleRate ?? 48000; + const args = [ "-i", inPath, "-af", - "loudnorm=I=-16:TP=-1.5:LRA=11", + `loudnorm=I=-16:TP=-1.5:LRA=11,aresample=${sr}`, "-c:v", "copy", - "-c:a", - resolveEncoder("aac"), - "-b:a", - "192k", + ...audioEncodeArgsForContainer(origExt), outPath, ]; diff --git a/apps/api/src/routes/tools/video-speed.ts b/apps/api/src/routes/tools/video-speed.ts index 5c817cd7..51da212a 100644 --- a/apps/api/src/routes/tools/video-speed.ts +++ b/apps/api/src/routes/tools/video-speed.ts @@ -1,12 +1,14 @@ import { extname, join } from "node:path"; -import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import { probeMedia } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { + audioEncodeArgsForContainer, buildAtempoChain, runFfmpegWithProgress, stageMediaInputs, videoContentType, + videoEncodeArgsForContainer, } from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; @@ -54,16 +56,8 @@ export function registerVideoSpeed(app: FastifyInstance) { "[v]", "-map", "[a]", - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", - "-c:a", - resolveEncoder("aac"), + ...videoEncodeArgsForContainer(origExt), + ...audioEncodeArgsForContainer(origExt), ]; } else { args = [ @@ -72,14 +66,7 @@ export function registerVideoSpeed(app: FastifyInstance) { "-vf", `setpts=PTS/${settings.factor}`, "-an", - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), ]; } diff --git a/apps/api/src/routes/tools/vignette.ts b/apps/api/src/routes/tools/vignette.ts index 15f54502..de8fd1c0 100644 --- a/apps/api/src/routes/tools/vignette.ts +++ b/apps/api/src/routes/tools/vignette.ts @@ -10,6 +10,11 @@ const settingsSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6}$/) .default("#000000"), + radius: z.number().int().min(0).max(100).default(70), + softness: z.number().int().min(0).max(100).default(50), + roundness: z.number().int().min(0).max(100).default(100), + centerX: z.number().int().min(0).max(100).default(50), + centerY: z.number().int().min(0).max(100).default(50), }); export function registerVignette(app: FastifyInstance) { @@ -21,11 +26,32 @@ export function registerVignette(app: FastifyInstance) { const w = meta.width ?? 1; const h = meta.height ?? 1; + const { radius, softness, roundness, centerX, centerY } = settings; + + // Outer radius of the gradient (percentage of the half-diagonal) + const outerR = radius / 100; + // Inner transparent stop: higher softness pushes it inward (more feather) + const innerStop = Math.max(0, Math.min(1, outerR * (1 - softness / 100))); + + // For roundness < 100, stretch the gradient to match image aspect ratio. + // At roundness 0 the gradient is fully elliptical (matching the image AR); + // at roundness 100 it is a perfect circle. + const ar = w / h; + const roundFactor = roundness / 100; + // scaleX: interpolate from aspect ratio to 1 as roundness goes 0..100 + const scaleX = ar >= 1 ? 1 : 1 / (roundFactor + (1 - roundFactor) * ar); + const scaleY = ar >= 1 ? roundFactor + (1 - roundFactor) / ar : 1; + + const gradientTransform = + roundness < 100 + ? ` gradientTransform="translate(${centerX / 100} ${centerY / 100}) scale(${scaleX.toFixed(6)} ${scaleY.toFixed(6)}) translate(-${centerX / 100} -${centerY / 100})"` + : ""; + // Build radial-gradient SVG overlay const svg = Buffer.from( `` + - `` + - `` + + `` + + `` + `` + `` + `` + diff --git a/apps/api/src/routes/tools/watermark-video.ts b/apps/api/src/routes/tools/watermark-video.ts index 42547ef4..5d976717 100644 --- a/apps/api/src/routes/tools/watermark-video.ts +++ b/apps/api/src/routes/tools/watermark-video.ts @@ -1,9 +1,13 @@ import { writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; -import { resolveEncoder, resolveFontFile } from "@snapotter/media-engine"; +import { resolveFontFile } from "@snapotter/media-engine"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { + runMediaTool, + videoContentType, + videoEncodeArgsForContainer, +} from "../../lib/media-tool.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ @@ -61,14 +65,7 @@ export function registerWatermarkVideo(app: FastifyInstance) { inPath, "-vf", vf, - "-c:v", - resolveEncoder("h264"), - "-crf", - "20", - "-preset", - "medium", - "-pix_fmt", - "yuv420p", + ...videoEncodeArgsForContainer(origExt), "-c:a", "copy", out, diff --git a/apps/api/src/routes/tools/xml-to-csv.ts b/apps/api/src/routes/tools/xml-to-csv.ts index 8390e73a..c5f9b675 100644 --- a/apps/api/src/routes/tools/xml-to-csv.ts +++ b/apps/api/src/routes/tools/xml-to-csv.ts @@ -29,17 +29,43 @@ function findFirstArray(node: unknown): Record[] | null { return null; } +/** + * Fallback for a single (non-repeating) record: depth-first, find the first + * object whose values are all scalar (a leaf record), skipping the XML + * declaration (keys starting with "?"). Lets a 1-element XML still tabulate. + */ +function findFirstRecord(node: unknown): Record | null { + if (typeof node !== "object" || node === null || Array.isArray(node)) return null; + const entries = Object.entries(node as Record).filter( + ([k]) => !k.startsWith("?"), + ); + const objectChildren = entries.filter(([, v]) => typeof v === "object" && v !== null); + const hasScalar = entries.some(([, v]) => v === null || typeof v !== "object"); + if (hasScalar && objectChildren.length === 0) { + return Object.fromEntries(entries); + } + for (const [, v] of objectChildren) { + const found = findFirstRecord(v); + if (found) return found; + } + return null; +} + /** * Flatten one level: nested objects and arrays become JSON strings in the cell. */ function flattenRow(row: Record): Record { const out: Record = {}; + const original = new Set(Object.keys(row)); for (const [key, val] of Object.entries(row)) { - if (val !== null && typeof val === "object") { - out[key] = JSON.stringify(val); - } else { - out[key] = val; - } + // Strip fast-xml-parser's internal markers from column names: "@_" on + // attributes and "#text" for an element's text content -- unless a sibling + // element already owns the cleaned-up name. + let bare = key; + if (key.startsWith("@_")) bare = key.slice(2); + else if (key === "#text") bare = "text"; + const outKey = bare !== key && original.has(bare) ? key : bare; + out[outKey] = val !== null && typeof val === "object" ? JSON.stringify(val) : val; } return out; } @@ -71,13 +97,21 @@ export function registerXmlToCsv(app: FastifyInstance) { throw new InputValidationError(`XML parse failed: ${msg.split("\n")[0]}`); } - const rows = findFirstArray(parsed); + let rows = findFirstArray(parsed); + if (!rows) { + // No repeating array: fall back to a single record (1-row table). + const single = findFirstRecord(parsed); + if (single) rows = [single]; + } if (!rows || rows.length === 0) { throw new InputValidationError("No repeating elements found to tabulate"); } const flattened = rows.map(flattenRow); - const csv = Papa.unparse(flattened); + // Papa.unparse derives columns from the first row only; pass the union of + // all keys so heterogeneous records don't silently drop columns. + const columns = Array.from(new Set(flattened.flatMap((row) => Object.keys(row)))); + const csv = Papa.unparse(flattened, { columns }); return { buffer: Buffer.from(csv, "utf8"), diff --git a/apps/api/src/routes/tools/yaml-json.ts b/apps/api/src/routes/tools/yaml-json.ts index 862b16d0..e504f41b 100644 --- a/apps/api/src/routes/tools/yaml-json.ts +++ b/apps/api/src/routes/tools/yaml-json.ts @@ -51,7 +51,10 @@ export function registerYamlJson(app: FastifyInstance) { const msg = err instanceof Error ? err.message : String(err); throw new InputValidationError(`Not valid YAML: ${msg.split("\n")[0]}`); } - const json = JSON.stringify(parsed, null, 2); + // js-yaml returns undefined for empty/comment-only documents; normalize to + // null so JSON.stringify yields the string "null" instead of undefined + // (Buffer.from(undefined) throws). + const json = JSON.stringify(parsed ?? null, null, 2); return { buffer: Buffer.from(json, "utf8"), filename: `${base}.json`, diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 0104d354..d480cbb9 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -430,7 +430,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { return reply.status(404).send({ error: "File not found" }); } - let stream; + let stream: Awaited>; try { stream = await streamStoredFile(file.storedName); } catch { diff --git a/apps/web/src/components/common/review-panel.tsx b/apps/web/src/components/common/review-panel.tsx index a11241a3..bb4ee165 100644 --- a/apps/web/src/components/common/review-panel.tsx +++ b/apps/web/src/components/common/review-panel.tsx @@ -134,24 +134,14 @@ export function ReviewPanel({ {t.toolPage.processed} {formatFileSize(fileSize)} -
- {t.toolPage.saved} - 0 - ? "text-emerald-600" - : sizeDelta === 0 - ? "text-muted-foreground" - : "text-foreground" - }`} - > - {sizeDelta === 0 - ? t.toolPage.noChange - : sizeDelta > 0 - ? `-${sizeDelta}%` - : `+${Math.abs(sizeDelta)}%`} - -
+ {/* Only claim "Saved" when the output is actually smaller; growth or + no-change is already visible from the Original/Processed sizes above. */} + {sizeDelta > 0 && ( +
+ {t.toolPage.saved} + {sizeDelta}% +
+ )} )} diff --git a/apps/web/src/components/layout/app-layout.tsx b/apps/web/src/components/layout/app-layout.tsx index 464df068..0d05e911 100644 --- a/apps/web/src/components/layout/app-layout.tsx +++ b/apps/web/src/components/layout/app-layout.tsx @@ -10,7 +10,7 @@ import { TopNav } from "./top-nav.js"; interface AppLayoutProps { children?: React.ReactNode; - breadcrumb?: { modality?: string; toolName?: string }; + breadcrumb?: { modality?: string; modalityTab?: string; toolName?: string }; navVariant?: "light" | "dark"; } diff --git a/apps/web/src/components/layout/top-nav.tsx b/apps/web/src/components/layout/top-nav.tsx index 69a71d99..fe54d0eb 100644 --- a/apps/web/src/components/layout/top-nav.tsx +++ b/apps/web/src/components/layout/top-nav.tsx @@ -22,7 +22,7 @@ import { AvatarDropdown } from "./avatar-dropdown.js"; interface TopNavProps { variant?: "light" | "dark"; - breadcrumb?: { modality?: string; toolName?: string }; + breadcrumb?: { modality?: string; modalityTab?: string; toolName?: string }; onHelpClick: () => void; onSettingsClick: () => void; } @@ -87,7 +87,13 @@ export function TopNav({ {breadcrumb.modality && ( - {breadcrumb.modality} + {breadcrumb.modalityTab ? ( + + {breadcrumb.modality} + + ) : ( + breadcrumb.modality + )} {" / "} )} @@ -154,9 +160,23 @@ export function TopNav({ isDark ? "text-[#666]" : "text-muted-foreground/50", )} /> - - {breadcrumb.modality} - + {breadcrumb.modalityTab ? ( + + {breadcrumb.modality} + + ) : ( + + {breadcrumb.modality} + + )} )} ("color"); const [color, setColor] = useState("#ffffff"); + const [gradientColor1, setGradientColor1] = useState("#ffffff"); + const [gradientColor2, setGradientColor2] = useState("#000000"); + const [gradientAngle, setGradientAngle] = useState(180); + const [feather, setFeather] = useState(0); + const [outputFormat, setOutputFormat] = useState("png"); const ts = t.toolSettings["background-replace"]; const hasFile = files.length > 0; const hasMultiple = files.length > 1; const handleProcess = () => { - const settings = { color }; + const settings = { + backgroundType, + color, + ...(backgroundType === "gradient" && { + gradientColor1, + gradientColor2, + gradientAngle, + }), + feather, + format: outputFormat, + }; if (hasMultiple) { processAllFiles(files, settings); } else { @@ -34,29 +53,168 @@ export function BackgroundReplaceSettings() { return (
- {/* Color */} + {/* Background Type Toggle */}
-
+ + {/* Solid Color Picker */} + {backgroundType === "color" && ( +
+ +
+ setColor(e.target.value)} + className="h-10 w-14 cursor-pointer rounded-md border border-border" + /> + { + const v = e.target.value; + if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setColor(v); + }} + className="border-border bg-background w-28 rounded-md border px-3 py-2 font-mono text-sm" + maxLength={7} + /> +
+
+ )} + + {/* Gradient Controls */} + {backgroundType === "gradient" && ( + <> +
+
+ +
+ setGradientColor1(e.target.value)} + className="h-9 w-12 cursor-pointer rounded-md border border-border" + /> + { + const v = e.target.value; + if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setGradientColor1(v); + }} + className="border-border bg-background w-full rounded-md border px-2 py-1.5 font-mono text-xs" + maxLength={7} + /> +
+
+
+ +
+ setGradientColor2(e.target.value)} + className="h-9 w-12 cursor-pointer rounded-md border border-border" + /> + { + const v = e.target.value; + if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setGradientColor2(v); + }} + className="border-border bg-background w-full rounded-md border px-2 py-1.5 font-mono text-xs" + maxLength={7} + /> +
+
+
+
+ + setGradientAngle(Number(e.target.value))} + className="w-full" + /> +
+ + )} + + {/* Edge Feather */} +
+ -
- setColor(e.target.value)} - className="h-10 w-14 cursor-pointer rounded-md border border-border" - /> - { - const v = e.target.value; - if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setColor(v); - }} - className="border-border bg-background w-28 rounded-md border px-3 py-2 font-mono text-sm" - maxLength={7} - /> + setFeather(Number(e.target.value))} + className="w-full" + /> +
+ + {/* Output Format Toggle */} +
+ Output Format +
+ {(["png", "webp"] as const).map((fmt) => ( + + ))}
diff --git a/apps/web/src/components/tools/blur-background-settings.tsx b/apps/web/src/components/tools/blur-background-settings.tsx index 05b1aa4b..a0309872 100644 --- a/apps/web/src/components/tools/blur-background-settings.tsx +++ b/apps/web/src/components/tools/blur-background-settings.tsx @@ -6,6 +6,8 @@ import { useToolProcessor } from "@/hooks/use-tool-processor"; import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; +type OutputFormat = "png" | "webp"; + export function BlurBackgroundSettings() { const { t } = useTranslation(); const { files } = useFileStore(); @@ -13,13 +15,15 @@ export function BlurBackgroundSettings() { useToolProcessor("blur-background"); const [intensity, setIntensity] = useState(50); + const [feather, setFeather] = useState(0); + const [outputFormat, setOutputFormat] = useState("png"); const ts = t.toolSettings["blur-background"]; const hasFile = files.length > 0; const hasMultiple = files.length > 1; const handleProcess = () => { - const settings = { intensity }; + const settings = { intensity, feather, format: outputFormat }; if (hasMultiple) { processAllFiles(files, settings); } else { @@ -41,6 +45,7 @@ export function BlurBackgroundSettings() {
+ {/* Edge Feather */} +
+
+ + + {feather === 0 ? "Off" : `${feather}px`} + +
+ setFeather(Number(e.target.value))} + className="w-full" + /> +

+ Softens the edge between subject and blurred background +

+
+ + {/* Output Format */} +
+

Output Format

+
+ {(["png", "webp"] as const).map((fmt) => ( + + ))} +
+
+ {/* Progress / Submit */} {processing && progress ? ( @@ -82,6 +135,7 @@ export function BlurBackgroundSettings() { diff --git a/apps/web/src/components/tools/circle-crop-settings.tsx b/apps/web/src/components/tools/circle-crop-settings.tsx index 9d5f5f21..b4be67cf 100644 --- a/apps/web/src/components/tools/circle-crop-settings.tsx +++ b/apps/web/src/components/tools/circle-crop-settings.tsx @@ -1,36 +1,242 @@ import { Download } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; +const PREVIEW_D = 200; // circle diameter in the inline preview, px + export function CircleCropSettings() { const { t } = useTranslation(); const { files } = useFileStore(); + const entry = useFileStore((s) => s.entries[s.selectedIndex]); + const blobUrl = entry?.blobUrl; const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = useToolProcessor("circle-crop"); - const handleProcess = () => { - if (files.length > 1) { - processAllFiles(files, {}); - } else { - processFiles(files, {}); + const [dims, setDims] = useState<{ w: number; h: number } | null>(null); + const [zoom, setZoom] = useState(1); + const [offsetX, setOffsetX] = useState(0.5); + const [offsetY, setOffsetY] = useState(0.5); + const [borderWidth, setBorderWidth] = useState(0); + const [borderColor, setBorderColor] = useState("#ffffff"); + const [bgMode, setBgMode] = useState<"transparent" | "color">("transparent"); + const [bgColor, setBgColor] = useState("#ffffff"); + const [outputSize, setOutputSize] = useState(""); + + // Natural image dimensions, for accurate framing math. + useEffect(() => { + if (!blobUrl) { + setDims(null); + return; } + const img = new Image(); + img.onload = () => setDims({ w: img.naturalWidth, h: img.naturalHeight }); + img.src = blobUrl; + }, [blobUrl]); + + // Preview geometry (mirrors the backend region math). + const W = dims?.w ?? 1; + const H = dims?.h ?? 1; + const d = Math.max(1, Math.min(W, H) / zoom); + const scale = PREVIEW_D / d; + const left = (W - d) * offsetX; + const top = (H - d) * offsetY; + const bwPx = borderWidth * scale; + + // Drag-to-pan the circle. + const drag = useRef<{ px: number; py: number; ox: number; oy: number } | null>(null); + const onPointerDown = (e: React.PointerEvent) => { + if (!dims) return; + e.currentTarget.setPointerCapture(e.pointerId); + drag.current = { px: e.clientX, py: e.clientY, ox: offsetX, oy: offsetY }; + }; + const onPointerMove = (e: React.PointerEvent) => { + if (!drag.current || !dims) return; + const dx = e.clientX - drag.current.px; + const dy = e.clientY - drag.current.py; + const rangeX = W - d; + const rangeY = H - d; + if (rangeX > 0) { + const nl = Math.min(Math.max(drag.current.ox * rangeX - dx / scale, 0), rangeX); + setOffsetX(nl / rangeX); + } + if (rangeY > 0) { + const nt = Math.min(Math.max(drag.current.oy * rangeY - dy / scale, 0), rangeY); + setOffsetY(nt / rangeY); + } + }; + const onPointerUp = () => { + drag.current = null; + }; + + const handleProcess = () => { + const settings: Record = { + zoom, + offsetX, + offsetY, + borderWidth, + borderColor, + background: bgMode === "transparent" ? "transparent" : bgColor, + }; + const sz = Number.parseInt(outputSize, 10); + if (!Number.isNaN(sz) && sz >= 16) settings.outputSize = sz; + if (files.length > 1) processAllFiles(files, settings); + else processFiles(files, settings); }; const hasFile = files.length > 0; - const canProcess = hasFile && !processing; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (canProcess) handleProcess(); - }; return ( - -

- Crops the image to a centered circle with transparent corners. Output is always PNG. -

+
+ {/* Live framing preview */} + {hasFile && blobUrl && ( +
+
0 ? borderColor : "transparent", + }} + > +
+ {/* checkerboard hint for transparency */} + {bgMode === "transparent" && ( +
+ )} + +
+
+
+ )} + {hasFile && ( +

+ Drag the preview to reposition +

+ )} + + {/* Zoom */} +
+
+ Zoom + {zoom.toFixed(1)}x +
+ setZoom(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Border */} +
+
+ Border + {borderWidth}px +
+
+ setBorderWidth(Number(e.target.value))} + className="flex-1 min-w-0" + /> + setBorderColor(e.target.value)} + aria-label="Border color" + className="h-7 w-9 shrink-0 rounded border border-border bg-background" + /> +
+
+ + {/* Background */} +
+ Background +
+
+ + +
+ {bgMode === "color" && ( + setBgColor(e.target.value)} + aria-label="Background color" + className="h-7 w-9 shrink-0 rounded border border-border bg-background" + /> + )} +
+
+ + {/* Output size */} +
+ + setOutputSize(e.target.value)} + placeholder="Original" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + /> +
{error &&

{error}

} @@ -45,10 +251,11 @@ export function CircleCropSettings() { /> ) : (
); } diff --git a/apps/web/src/components/tools/color-palette-settings.tsx b/apps/web/src/components/tools/color-palette-settings.tsx index 13e0cb3d..9cd598b1 100644 --- a/apps/web/src/components/tools/color-palette-settings.tsx +++ b/apps/web/src/components/tools/color-palette-settings.tsx @@ -1,15 +1,22 @@ -import { Check, Copy, Loader2 } from "lucide-react"; +import { Check, ClipboardCopy, Copy, Loader2 } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; import { copyToClipboard } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; + +type ColorFormat = "hex" | "rgb" | "hsl"; + export function ColorPaletteSettings() { const { t } = useTranslation(); const ts = t.toolSettings["color-palette"]; const { files, processing, error, setProcessing, setError } = useFileStore(); + const [count, setCount] = useState(8); + const [format, setFormat] = useState("hex"); const [colors, setColors] = useState([]); + const [hexColors, setHexColors] = useState([]); const [copiedIdx, setCopiedIdx] = useState(null); + const [copiedExport, setCopiedExport] = useState<"css" | "json" | null>(null); const handleProcess = async () => { if (files.length === 0) return; @@ -17,10 +24,12 @@ export function ColorPaletteSettings() { setProcessing(true); setError(null); setColors([]); + setHexColors([]); try { const formData = new FormData(); formData.append("file", files[0]); + formData.append("settings", JSON.stringify({ count, format })); const res = await fetch("/api/v1/tools/color-palette", { method: "POST", @@ -35,6 +44,7 @@ export function ColorPaletteSettings() { const data = await res.json(); setColors(data.colors); + setHexColors(data.hex); } catch (err) { setError(err instanceof Error ? err.message : ts.extracting); } finally { @@ -50,10 +60,65 @@ export function ColorPaletteSettings() { } }; + const copyCss = async () => { + const vars = hexColors.map((c, i) => ` --color-${i + 1}: ${c};`).join("\n"); + const css = `:root {\n${vars}\n}`; + const ok = await copyToClipboard(css); + if (ok) { + setCopiedExport("css"); + setTimeout(() => setCopiedExport(null), 1500); + } + }; + + const copyJson = async () => { + const ok = await copyToClipboard(JSON.stringify(colors)); + if (ok) { + setCopiedExport("json"); + setTimeout(() => setCopiedExport(null), 1500); + } + }; + const hasFile = files.length > 0; return (
+ {/* Color count slider */} +
+
+ Colors + {count} +
+ setCount(Number(e.target.value))} + className="w-full mt-1" + data-testid="color-palette-count" + /> +
+ + {/* Format toggle */} +
+ Format +
+ {(["hex", "rgb", "hsl"] as const).map((f) => ( + + ))} +
+
+ + {/* Extract button */} + +
+
+ + {/* Swatch list */}
{colors.map((color, i) => ( + ))} +
+ + {/* Shadow Color */}
+ {/* Intensity Slider */} +
+
+ + {intensity}% +
+ setIntensity(Number(e.target.value))} + className="w-full mt-0.5" + /> +
+ {error &&

{error}

} {processing ? ( diff --git a/apps/web/src/components/tools/erase-object-settings.tsx b/apps/web/src/components/tools/erase-object-settings.tsx index f6a01e72..7b82c27c 100644 --- a/apps/web/src/components/tools/erase-object-settings.tsx +++ b/apps/web/src/components/tools/erase-object-settings.tsx @@ -1,5 +1,5 @@ import { Download, Redo, Trash2 } from "lucide-react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; @@ -21,6 +21,97 @@ const OUTPUT_FORMATS = [ ] as const; const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl"]; +const SSE_STALL_TIMEOUT_MS = 5 * 60_000; + +interface ProgressHandlers { + onProgress?: (percent: number) => void; + onComplete: (result: Record) => void; + onFailed: (error: string) => void; + onStall: () => void; +} + +/** + * Subscribe to async (202) job progress with the same resilience as the + * standard tool processor (PRs #203/#204). The original eraser opened a bare + * EventSource with no recovery: if SSE silently died (mobile backgrounding, + * flaky network, proxy buffering) the UI hung forever at the last percent + * (~25%) even though the backend job had finished and saved its result. + * + * This reconnects on tab refocus -- the progress endpoint replays the + * terminal frame from its 10-minute Redis cache, so a job that completed + * while SSE was dead still resolves -- and arms a stall timeout that fails + * gracefully instead of hanging. Returns a cleanup the caller must invoke on + * sync completion, error, or unmount. + */ +function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers): () => void { + let es: EventSource | null = null; + let stall: ReturnType | null = null; + let done = false; + + const onVisible = () => { + if (done || document.visibilityState !== "visible") return; + if (es && es.readyState === EventSource.OPEN) return; + setTimeout(open, 500); + }; + + const cleanup = () => { + if (done) return; + done = true; + if (stall) clearTimeout(stall); + stall = null; + if (es) es.close(); + es = null; + document.removeEventListener("visibilitychange", onVisible); + }; + + const resetStall = () => { + if (stall) clearTimeout(stall); + stall = setTimeout(() => { + cleanup(); + handlers.onStall(); + }, SSE_STALL_TIMEOUT_MS); + }; + + function open() { + if (done) return; + if (es && es.readyState === EventSource.OPEN) return; + if (es) es.close(); + try { + es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); + } catch { + return; + } + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type !== "single") return; + resetStall(); + if (data.phase === "complete" && data.result) { + cleanup(); + handlers.onComplete(data.result as Record); + return; + } + if (data.phase === "failed") { + cleanup(); + handlers.onFailed(typeof data.error === "string" ? data.error : "Processing failed"); + return; + } + if (typeof data.percent === "number") handlers.onProgress?.(data.percent); + } catch { + // Ignore malformed SSE frames + } + }; + // A transient drop triggers the browser's built-in reconnect; on reconnect + // the backend replays the terminal frame, so a completed job still resolves. + es.onerror = () => {}; + } + + document.addEventListener("visibilitychange", onVisible); + open(); + resetStall(); + return cleanup; +} + interface EraseObjectSettingsProps { eraserRef: React.RefObject; hasStrokes: boolean; @@ -39,21 +130,22 @@ export function EraseObjectSettings({ maskedFileCount, }: EraseObjectSettingsProps) { const { t } = useTranslation(); - const { - files, - entries, - selectedIndex, - processing, - error, - setProcessing, - setError, - currentEntry, - } = useFileStore(); + const { files, entries, processing, error, setProcessing, setError, currentEntry } = + useFileStore(); const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle"); const [progressPercent, setProgressPercent] = useState(0); const [progressStage, setProgressStage] = useState(null); const [elapsed, setElapsed] = useState(0); const elapsedRef = useRef | null>(null); + const progressCleanupRef = useRef<(() => void) | null>(null); + + // Tear down any live progress subscription if the component unmounts mid-job. + useEffect(() => { + return () => { + progressCleanupRef.current?.(); + if (elapsedRef.current) clearInterval(elapsedRef.current); + }; + }, []); const [outputFormat, setOutputFormat] = useState("png"); const [quality, setQuality] = useState(95); @@ -66,41 +158,29 @@ export function EraseObjectSettings({ ): Promise => { return new Promise((resolve, reject) => { const clientJobId = generateId(); - let asyncMode = false; - const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); - es.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.type !== "single") return; - if (data.phase === "complete" && data.result) { - es.close(); - const r = data.result; - useFileStore.getState().updateEntry(entryIndex, { - processedUrl: r.downloadUrl, - processedPreviewUrl: r.previewUrl ?? null, - processedFilename: null, - status: "completed", - originalSize: r.originalSize, - processedSize: r.processedSize, - }); - resolve(); - return; - } - if (data.phase === "failed" && asyncMode) { - es.close(); - reject(new Error(data.error || "Processing failed")); - return; - } - if (typeof data.percent === "number") { - onProgress(data.percent); - } - } catch {} - }; - es.onerror = () => { - if (!asyncMode) es.close(); + const applyResult = (r: Record) => { + useFileStore.getState().updateEntry(entryIndex, { + processedUrl: r.downloadUrl as string, + processedPreviewUrl: (r.previewUrl as string) ?? null, + processedFilename: null, + status: "completed", + originalSize: r.originalSize as number, + processedSize: r.processedSize as number, + }); }; + const stopProgress = subscribeJobProgress(clientJobId, { + onProgress, + onComplete: (r) => { + applyResult(r); + resolve(); + }, + onFailed: (err) => reject(new Error(err)), + onStall: () => + reject(new Error("Processing timed out. The result may have saved -- check your files.")), + }); + const maskFile = new File([maskBlob], "mask.png", { type: "image/png" }); const formData = new FormData(); formData.append("file", file); @@ -112,22 +192,11 @@ export function EraseObjectSettings({ const xhr = new XMLHttpRequest(); xhr.timeout = 600_000; xhr.onload = () => { - if (xhr.status === 202) { - asyncMode = true; - return; - } - es.close(); + if (xhr.status === 202) return; + stopProgress(); if (xhr.status >= 200 && xhr.status < 300) { try { - const data = JSON.parse(xhr.responseText); - useFileStore.getState().updateEntry(entryIndex, { - processedUrl: data.downloadUrl, - processedPreviewUrl: data.previewUrl ?? null, - processedFilename: null, - status: "completed", - originalSize: data.originalSize, - processedSize: data.processedSize, - }); + applyResult(JSON.parse(xhr.responseText)); resolve(); } catch { reject(new Error("Invalid response")); @@ -150,11 +219,11 @@ export function EraseObjectSettings({ } }; xhr.onerror = () => { - es.close(); + stopProgress(); reject(new Error("Network error")); }; xhr.ontimeout = () => { - es.close(); + stopProgress(); reject(new Error("Request timed out")); }; xhr.open("POST", "/api/v1/tools/erase-object"); @@ -191,51 +260,50 @@ export function EraseObjectSettings({ }, 1000); const clientJobId = generateId(); - let asyncMode = false; - const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); - es.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.type !== "single") return; - - if (data.phase === "complete" && data.result) { - if (elapsedRef.current) clearInterval(elapsedRef.current); - es.close(); - const r = data.result; - useFileStore.getState().updateEntry(capturedIndex, { - processedUrl: r.downloadUrl, - processedPreviewUrl: r.previewUrl ?? null, - processedFilename: null, - status: "completed", - originalSize: r.originalSize, - processedSize: r.processedSize, - }); - setProcessing(false); - setProgressPhase("idle"); - setProgressStage(null); - return; - } - - if (data.phase === "failed" && asyncMode) { - if (elapsedRef.current) clearInterval(elapsedRef.current); - es.close(); - setError(data.error || "Processing failed"); - setProcessing(false); - setProgressPhase("idle"); - return; - } - - if (typeof data.percent === "number") { - setProgressPhase("processing"); - setProgressPercent(15 + (data.percent / 100) * 85); - } - } catch {} + const applyResult = (r: Record) => { + useFileStore.getState().updateEntry(capturedIndex, { + processedUrl: r.downloadUrl as string, + processedPreviewUrl: (r.previewUrl as string) ?? null, + processedFilename: null, + status: "completed", + originalSize: r.originalSize as number, + processedSize: r.processedSize as number, + }); }; - es.onerror = () => { - if (!asyncMode) es.close(); + + const finishUi = () => { + if (elapsedRef.current) clearInterval(elapsedRef.current); + setProcessing(false); + setProgressPhase("idle"); + setProgressStage(null); }; + const stopProgress = subscribeJobProgress(clientJobId, { + onProgress: (percent) => { + setProgressPhase("processing"); + setProgressPercent(15 + (percent / 100) * 85); + }, + onComplete: (r) => { + progressCleanupRef.current = null; + applyResult(r); + finishUi(); + }, + onFailed: (err) => { + progressCleanupRef.current = null; + setError(err); + finishUi(); + }, + onStall: () => { + progressCleanupRef.current = null; + setError( + "Processing timed out with no progress. The result may have saved to your files -- otherwise, try again.", + ); + finishUi(); + }, + }); + progressCleanupRef.current = stopProgress; + const maskFile = new File([maskBlob], "mask.png", { type: "image/png" }); const formData = new FormData(); @@ -257,25 +325,15 @@ export function EraseObjectSettings({ setProgressPercent(15); }; xhr.onload = () => { - if (xhr.status === 202) { - asyncMode = true; - return; - } + // 202 = async: subscribeJobProgress drives completion via SSE. + if (xhr.status === 202) return; - if (elapsedRef.current) clearInterval(elapsedRef.current); - es.close(); + stopProgress(); + progressCleanupRef.current = null; if (xhr.status >= 200 && xhr.status < 300) { try { - const data = JSON.parse(xhr.responseText); - useFileStore.getState().updateEntry(capturedIndex, { - processedUrl: data.downloadUrl, - processedPreviewUrl: data.previewUrl ?? null, - processedFilename: null, - status: "completed", - originalSize: data.originalSize, - processedSize: data.processedSize, - }); + applyResult(JSON.parse(xhr.responseText)); } catch { setError("Invalid response"); } @@ -293,23 +351,19 @@ export function EraseObjectSettings({ setError(`Processing failed: ${xhr.status}`); } } - setProcessing(false); - setProgressPhase("idle"); - setProgressStage(null); + finishUi(); }; xhr.onerror = () => { - if (elapsedRef.current) clearInterval(elapsedRef.current); - es.close(); + stopProgress(); + progressCleanupRef.current = null; setError("Network error"); - setProcessing(false); - setProgressPhase("idle"); + finishUi(); }; xhr.ontimeout = () => { - if (elapsedRef.current) clearInterval(elapsedRef.current); - es.close(); + stopProgress(); + progressCleanupRef.current = null; setError("Request timed out - the server may be overloaded. Try again."); - setProcessing(false); - setProgressPhase("idle"); + finishUi(); }; xhr.open("POST", "/api/v1/tools/erase-object"); formatHeaders().forEach((value, key) => { diff --git a/apps/web/src/components/tools/favicon-settings.tsx b/apps/web/src/components/tools/favicon-settings.tsx index 0140ba6c..7d83aee6 100644 --- a/apps/web/src/components/tools/favicon-settings.tsx +++ b/apps/web/src/components/tools/favicon-settings.tsx @@ -7,19 +7,33 @@ import { formatHeaders } from "@/lib/api"; import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; -const SIZES = [ - { name: "favicon-16x16.png", size: "16x16" }, - { name: "favicon-32x32.png", size: "32x32" }, - { name: "favicon-48x48.png", size: "48x48" }, - { name: "apple-touch-icon.png", size: "180x180" }, - { name: "android-chrome-192x192.png", size: "192x192" }, - { name: "android-chrome-512x512.png", size: "512x512" }, - { name: "favicon.ico", size: "32x32" }, +const SIZE_OPTIONS = [ + { size: 16, name: "favicon-16x16.png", label: "16x16" }, + { size: 32, name: "favicon-32x32.png", label: "32x32" }, + { size: 48, name: "favicon-48x48.png", label: "48x48" }, + { size: 180, name: "apple-touch-icon.png", label: "180x180" }, + { size: 192, name: "android-chrome-192x192.png", label: "192x192" }, + { size: 512, name: "android-chrome-512x512.png", label: "512x512" }, ]; +const ALL_SIZES = SIZE_OPTIONS.map((s) => s.size); +const PREVIEW_BOXES = [64, 48, 32]; + export function FaviconSettings() { const { t } = useTranslation(); const { files, error, setProcessing, setError } = useFileStore(); + const entry = useFileStore((s) => s.entries[s.selectedIndex]); + const blobUrl = entry?.blobUrl; + + // Settings state + const [bgMode, setBgMode] = useState<"transparent" | "color">("transparent"); + const [bgColor, setBgColor] = useState("#ffffff"); + const [padding, setPadding] = useState(0); + const [radius, setRadius] = useState(0); + const [themeColor, setThemeColor] = useState("#ffffff"); + const [selectedSizes, setSelectedSizes] = useState>(() => new Set(ALL_SIZES)); + + // Download / progress state const [downloadUrl, setDownloadUrl] = useState(null); const [busy, setBusy] = useState(false); const [progress, setProgress] = useState({ @@ -48,6 +62,15 @@ export function FaviconSettings() { setProcessing(false); }; + const toggleSize = (size: number) => { + setSelectedSizes((prev) => { + const next = new Set(prev); + if (next.has(size)) next.delete(size); + else next.add(size); + return next; + }); + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: cleanup uses only stable refs and state setters const handleProcess = useCallback(() => { if (files.length === 0) return; @@ -73,6 +96,20 @@ export function FaviconSettings() { formData.append("file", file); } + // Build settings + const settings: Record = { + padding, + radius, + themeColor, + }; + if (bgMode === "color") { + settings.background = bgColor; + } + if (selectedSizes.size < ALL_SIZES.length) { + settings.sizes = Array.from(selectedSizes); + } + formData.append("settings", JSON.stringify(settings)); + const xhr = new XMLHttpRequest(); xhrRef.current = xhr; xhr.responseType = "blob"; @@ -124,7 +161,18 @@ export function FaviconSettings() { xhr.setRequestHeader(key, value); }); xhr.send(formData); - }, [files, setProcessing, setError, downloadUrl]); + }, [ + files, + setProcessing, + setError, + downloadUrl, + bgMode, + bgColor, + padding, + radius, + themeColor, + selectedSizes, + ]); const hasFiles = files.length > 0; @@ -135,16 +183,167 @@ export function FaviconSettings() { {files.length > 1 && format(t.toolSettings.favicon.multipleHint, { count: files.length })}

+ {/* Live preview grid */} + {hasFiles && blobUrl && ( +
+

Preview

+
+ {PREVIEW_BOXES.map((px) => { + const insetPx = Math.round((px * padding) / 100); + return ( +
+
+ {bgMode === "transparent" && ( +
+ )} +
+ +
+
+ {px}px +
+ ); + })} +
+
+ )} + + {/* Background */} +
+ Background +
+
+ + +
+ {bgMode === "color" && ( + setBgColor(e.target.value)} + aria-label="Background color" + className="h-7 w-9 shrink-0 rounded border border-border bg-background" + /> + )} +
+
+ + {/* Padding */} +
+
+ Padding + {padding}% +
+ setPadding(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Radius */} +
+
+ Corner Radius + {radius}% +
+ setRadius(Number(e.target.value))} + className="w-full mt-1" + /> +
+ Square + Circle +
+
+ + {/* Theme color */} +
+
+ Theme Color + setThemeColor(e.target.value)} + aria-label="Theme color" + className="h-7 w-9 shrink-0 rounded border border-border bg-background" + /> +
+

+ Used in manifest.json for browser chrome +

+
+ + {/* Size checklist */}

{t.toolSettings.favicon.generatedSizes}

-
- {SIZES.map((s) => ( -
- {s.name} - {s.size} -
+
+ {SIZE_OPTIONS.map((s) => ( + ))}

diff --git a/apps/web/src/components/tools/gif-webp-settings.tsx b/apps/web/src/components/tools/gif-webp-settings.tsx index 9a76120f..30a9daf7 100644 --- a/apps/web/src/components/tools/gif-webp-settings.tsx +++ b/apps/web/src/components/tools/gif-webp-settings.tsx @@ -1,20 +1,38 @@ import { Download } from "lucide-react"; +import { useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; +function formatKB(bytes: number): string { + return `${(bytes / 1024).toFixed(1)} KB`; +} + export function GifWebpSettings() { const { t } = useTranslation(); const { files } = useFileStore(); - const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = - useToolProcessor("gif-webp"); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + progress, + originalSize, + processedSize, + } = useToolProcessor("gif-webp"); + + const [quality, setQuality] = useState(80); + const [lossless, setLossless] = useState(false); + const [resizePercent, setResizePercent] = useState(100); const handleProcess = () => { + const settings = { quality, lossless, resizePercent }; if (files.length > 1) { - processAllFiles(files, {}); + processAllFiles(files, settings); } else { - processFiles(files, {}); + processFiles(files, settings); } }; @@ -33,6 +51,61 @@ export function GifWebpSettings() { determined automatically by the input file format.

+ {/* Lossless toggle */} +
+ Compression +
+ + +
+
+ + {/* Quality slider (hidden when lossless) */} + {!lossless && ( +
+
+ Quality + {quality} +
+ setQuality(Number(e.target.value))} + className="w-full mt-1" + /> +
+ )} + + {/* Resize slider */} +
+
+ Resize + {resizePercent}% +
+ setResizePercent(Number(e.target.value))} + className="w-full mt-1" + /> +
+ {error &&

{error}

} {processing ? ( @@ -58,15 +131,22 @@ export function GifWebpSettings() { )} {downloadUrl && ( - - - {t.common.download} - + <> + + + {t.common.download} + + {originalSize != null && processedSize != null && ( +

+ {formatKB(originalSize)} → {formatKB(processedSize)} +

+ )} + )} ); diff --git a/apps/web/src/components/tools/histogram-settings.tsx b/apps/web/src/components/tools/histogram-settings.tsx index 96eda6b2..b0ee87e1 100644 --- a/apps/web/src/components/tools/histogram-settings.tsx +++ b/apps/web/src/components/tools/histogram-settings.tsx @@ -1,15 +1,70 @@ import { Download } from "lucide-react"; +import { useMemo, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; +const CHART_W = 280; +const CHART_H = 150; + +type ChannelKey = "r" | "g" | "b" | "lum"; + +const CHANNEL_META: Record = { + r: { label: "R", fill: "rgba(239,68,68,0.5)", dot: "bg-red-500" }, + g: { label: "G", fill: "rgba(34,197,94,0.5)", dot: "bg-green-500" }, + b: { label: "B", fill: "rgba(59,130,246,0.5)", dot: "bg-blue-500" }, + lum: { label: "L", fill: "rgba(120,120,120,0.5)", dot: "bg-neutral-400" }, +}; + +const ALL_CHANNELS: ChannelKey[] = ["r", "g", "b", "lum"]; + +function buildPath(bins: number[], maxVal: number, scale: "linear" | "log"): string { + if (maxVal === 0) return ""; + const xStep = CHART_W / 255; + const pts: string[] = [`M 0,${CHART_H}`]; + for (let i = 0; i < 256; i++) { + const x = (i * xStep).toFixed(1); + const v = bins[i]; + const norm = scale === "log" ? Math.log(1 + v) / Math.log(1 + maxVal) : v / maxVal; + const y = (CHART_H - norm * CHART_H).toFixed(1); + pts.push(`L ${x},${y}`); + } + pts.push(`L ${CHART_W},${CHART_H} Z`); + return pts.join(" "); +} + +type ChannelStats = { mean: number; median: number; stdev: number }; + export function HistogramSettings() { const { t } = useTranslation(); const { files } = useFileStore(); - const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, progress, resultPayload } = useToolProcessor("histogram"); + const [visible, setVisible] = useState>({ + r: true, + g: true, + b: true, + lum: false, + }); + const [scale, setScale] = useState<"linear" | "log">("linear"); + + const bins = resultPayload?.bins as Record | undefined; + const stats = resultPayload?.stats as Record | undefined; + + const maxVal = useMemo(() => { + if (!bins) return 0; + let m = 0; + for (const ch of ALL_CHANNELS) { + if (!visible[ch]) continue; + for (const v of bins[ch]) { + if (v > m) m = v; + } + } + return m; + }, [bins, visible]); + const handleProcess = () => { if (files.length > 1) { processAllFiles(files, {}); @@ -26,6 +81,10 @@ export function HistogramSettings() { if (canProcess) handleProcess(); }; + const toggleChannel = (ch: ChannelKey) => { + setVisible((prev) => ({ ...prev, [ch]: !prev[ch] })); + }; + return (

@@ -56,6 +115,98 @@ export function HistogramSettings() { )} + {bins && ( +

+ {/* Channel toggles + scale switch */} +
+ {ALL_CHANNELS.map((ch) => ( + + ))} +
+ + +
+
+ + {/* SVG histogram */} + + {ALL_CHANNELS.filter((ch) => visible[ch]).map((ch) => ( + + ))} + + + {/* Stats readout */} + {stats && ( +
+ {ALL_CHANNELS.filter((ch) => visible[ch]).map((ch) => ( +
+ + + {CHANNEL_META[ch].label}: {stats[ch].mean} / {stats[ch].median} /{" "} + {stats[ch].stdev} + +
+ ))} +

+ mean / median / stdev +

+
+ )} +
+ )} + {downloadUrl && ( ratio) return { cw: w, ch: Math.round(w / ratio) }; + return { cw: Math.round(h * ratio), ch: h }; +} + +interface ImagePadSettingsProps { + onImageStyle?: (style: React.CSSProperties | null) => void; + onImageOverlay?: (node: React.ReactNode) => void; +} + +export function ImagePadSettings({ onImageStyle, onImageOverlay }: ImagePadSettingsProps) { const { t } = useTranslation(); const { files } = useFileStore(); + const entry = useFileStore((s) => s.entries[s.selectedIndex]); + const blobUrl = entry?.blobUrl; const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = useToolProcessor("image-pad"); const [target, setTarget] = useState("1:1"); + const [ratioW, setRatioW] = useState(1); + const [ratioH, setRatioH] = useState(1); + const [background, setBackground] = useState<"color" | "transparent" | "blur">("color"); const [color, setColor] = useState("#ffffff"); + const [padding, setPadding] = useState(0); + const [dims, setDims] = useState<{ w: number; h: number } | null>(null); + + // Natural image dimensions + useEffect(() => { + if (!blobUrl) { + setDims(null); + return; + } + const img = new Image(); + img.onload = () => setDims({ w: img.naturalWidth, h: img.naturalHeight }); + img.src = blobUrl; + }, [blobUrl]); + + // Stable refs for preview callbacks to avoid stale closures + const onImageStyleRef = useRef(onImageStyle); + useEffect(() => { + onImageStyleRef.current = onImageStyle; + }); + const onImageOverlayRef = useRef(onImageOverlay); + useEffect(() => { + onImageOverlayRef.current = onImageOverlay; + }); + + // Live preview: padded-canvas overlay covering the pane image. + // onImageStyle({}) activates the wrapper branch in image-viewer + // so onImageOverlay children actually mount. + useEffect(() => { + if (!blobUrl || !dims) return; + + onImageStyleRef.current?.({}); + + const w = dims.w; + const h = dims.h; + const ratioStr = target === "custom" ? `${ratioW}:${ratioH}` : target; + const { cw, ch } = canvasFor(w, h, ratioStr); + const margin = padding > 0 ? Math.round((Math.max(cw, ch) * padding) / 100) : 0; + const finalW = cw + margin * 2; + const finalH = ch + margin * 2; + + // Fit the canvas (which may have a different AR) inside the overlay + // (which has the source image AR). + const canvasAR = finalW / finalH; + const overlayAR = w / h; + const [canvasWPct, canvasHPct] = + canvasAR > overlayAR + ? [100, (overlayAR / canvasAR) * 100] + : [(canvasAR / overlayAR) * 100, 100]; + + // Image positioning within the canvas + const imgWPct = (w / finalW) * 100; + const imgHPct = (h / finalH) * 100; + const imgLeftPct = ((finalW - w) / (2 * finalW)) * 100; + const imgTopPct = ((finalH - h) / (2 * finalH)) * 100; + + const checkerStyle: React.CSSProperties = { + backgroundImage: + "linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)", + backgroundSize: "16px 16px", + backgroundPosition: "0 0,0 8px,8px -8px,-8px 0", + backgroundColor: "#fff", + }; + + const bgStyle: React.CSSProperties = + background === "transparent" + ? checkerStyle + : background === "color" + ? { backgroundColor: color } + : {}; + + onImageOverlayRef.current?.( +
+
+ {background === "blur" && ( + + )} + +
+
, + ); + + return () => { + onImageStyleRef.current?.(null); + onImageOverlayRef.current?.(null); + }; + }, [blobUrl, dims, target, ratioW, ratioH, background, color, padding]); const handleProcess = () => { - const settings = { target, color }; + const settings = { target, ratioW, ratioH, background, color, padding }; if (files.length > 1) { processAllFiles(files, settings); } else { @@ -41,47 +167,109 @@ export function ImagePadSettings() { return ( - {/* Target Ratio */} + {/* Aspect Ratio */}
- - +
+ {target === "custom" && ( +
+ setRatioW(Math.max(1, Math.min(100, Number(e.target.value) || 1)))} + className="w-16 px-2 py-1 rounded border border-border bg-background text-sm text-foreground text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + /> + : + setRatioH(Math.max(1, Math.min(100, Number(e.target.value) || 1)))} + className="w-16 px-2 py-1 rounded border border-border bg-background text-sm text-foreground text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + /> +
+ )}
- {/* Background Color */} + {/* Background Type */}
- -
- setColor(e.target.value)} - className="w-8 h-8 rounded border border-border shrink-0" - /> - setColor(e.target.value)} - className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono" - /> +

Background

+
+ {(["color", "transparent", "blur"] as const).map((bg) => ( + + ))}
+ {/* Color Picker (only for "color" background) */} + {background === "color" && ( +
+ +
+ setColor(e.target.value)} + className="w-8 h-8 rounded border border-border shrink-0" + /> + setColor(e.target.value)} + className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono" + /> +
+
+ )} + + {/* Padding Slider */} +
+
+ Padding + {padding}% +
+ setPadding(Number(e.target.value))} + className="w-full mt-0.5" + /> +
+ {error &&

{error}

} {processing ? ( diff --git a/apps/web/src/components/tools/lqip-placeholder-settings.tsx b/apps/web/src/components/tools/lqip-placeholder-settings.tsx index 94e150f5..f0a475e4 100644 --- a/apps/web/src/components/tools/lqip-placeholder-settings.tsx +++ b/apps/web/src/components/tools/lqip-placeholder-settings.tsx @@ -1,21 +1,29 @@ -import { Download } from "lucide-react"; +import { Check, Copy, Download } from "lucide-react"; import { useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { copyToClipboard } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; +type Strategy = "blur" | "pixelate" | "solid"; +type Format = "webp" | "png" | "jpeg"; + export function LqipPlaceholderSettings() { const { t } = useTranslation(); const { files } = useFileStore(); - const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, progress, resultPayload } = useToolProcessor("lqip-placeholder"); const [width, setWidth] = useState(16); const [blur, setBlur] = useState(2); + const [strategy, setStrategy] = useState("blur"); + const [format, setFormat] = useState("webp"); + const [quality, setQuality] = useState(50); + const [copied, setCopied] = useState(null); const handleProcess = () => { - const settings = { width, blur }; + const settings = { width, blur, strategy, format, quality }; if (files.length > 1) { processAllFiles(files, settings); } else { @@ -31,8 +39,44 @@ export function LqipPlaceholderSettings() { if (canProcess) handleProcess(); }; + const handleCopy = async (text: string, label: string) => { + const ok = await copyToClipboard(text); + if (ok) { + setCopied(label); + setTimeout(() => setCopied(null), 1500); + } + }; + + const dataUri = resultPayload?.dataUri as string | undefined; + const resultWidth = resultPayload?.width as number | undefined; + const resultHeight = resultPayload?.height as number | undefined; + const resultBytes = resultPayload?.bytes as number | undefined; + const resultHtml = resultPayload?.html as string | undefined; + const resultCss = resultPayload?.css as string | undefined; + return ( + {/* Strategy */} +
+ Strategy +
+ {(["blur", "pixelate", "solid"] as const).map((s) => ( + + ))} +
+
+ {/* Width */}
@@ -52,28 +96,65 @@ export function LqipPlaceholderSettings() { />
- {/* Blur */} + {/* Blur (only for blur strategy) */} + {strategy === "blur" && ( +
+
+ + {blur} +
+ setBlur(Number(e.target.value))} + className="w-full mt-1" + /> +
+ )} + + {/* Format */}
-
- - {blur} + Format +
+ {(["webp", "png", "jpeg"] as const).map((f) => ( + + ))}
- setBlur(Number(e.target.value))} - className="w-full mt-1" - />
-

- The base64 data URI will appear in the result envelope. -

+ {/* Quality (only for webp/jpeg) */} + {format !== "png" && ( +
+
+ Quality + {quality} +
+ setQuality(Number(e.target.value))} + className="w-full mt-1" + /> +
+ )} {error &&

{error}

} @@ -113,6 +194,86 @@ export function LqipPlaceholderSettings() { {t.common.download}
)} + + {/* Output section */} + {dataUri && ( +
+ {/* Preview + stats */} +
+ LQIP preview +
+

+ {resultWidth} x {resultHeight} px +

+

{resultBytes} bytes

+
+
+ + {/* Data URI */} + handleCopy(dataUri, "dataUri")} + /> + + {/* HTML snippet */} + {resultHtml && ( + handleCopy(resultHtml, "html")} + /> + )} + + {/* CSS snippet */} + {resultCss && ( + handleCopy(resultCss, "css")} + /> + )} +
+ )} ); } + +function CopyBlock({ + label, + value, + copied, + onCopy, +}: { + label: string; + value: string; + copied: boolean; + onCopy: () => void; +}) { + return ( +
+
+ {label} + +
+
+ {value} +
+
+ ); +} diff --git a/apps/web/src/components/tools/ocr-pdf-view.tsx b/apps/web/src/components/tools/ocr-pdf-view.tsx new file mode 100644 index 00000000..4afd1576 --- /dev/null +++ b/apps/web/src/components/tools/ocr-pdf-view.tsx @@ -0,0 +1,98 @@ +import { Copy, FileText } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useFileStore } from "@/stores/file-store"; +import { DocumentView } from "./document-view"; + +/** + * Results panel for the OCR PDF tool: the original PDF on one side and the + * extracted text on the other. The tool's output is a .txt transcript, so the + * PDF preview is forced to the input (inputOnly) and the text is fetched from + * the processed download URL once OCR completes. + */ +export function OcrPdfView() { + const entry = useFileStore((s) => s.entries[s.selectedIndex]); + const processedUrl = entry?.processedUrl ?? null; + const status = entry?.status; + + const [text, setText] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!processedUrl) { + setText(null); + return; + } + let cancelled = false; + setLoading(true); + fetch(processedUrl) + .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) + .then((t) => { + if (!cancelled) setText(t); + }) + .catch(() => { + if (!cancelled) setText(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [processedUrl]); + + const copy = async () => { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // clipboard unavailable; ignore + } + }; + + return ( +
+ {/* Original PDF */} +
+ +
+ + {/* Extracted text */} +
+
+ Extracted Text + {text != null && ( + + )} +
+
+ {loading ? ( +

Loading extracted text...

+ ) : text != null ? ( +
+              {text || "(no text found)"}
+            
+ ) : ( +
+ +

+ {status === "processing" + ? "Extracting text..." + : "The extracted text will appear here after you run OCR."} +

+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/tools/pixelate-settings.tsx b/apps/web/src/components/tools/pixelate-settings.tsx index e3ca51b8..d2c5d183 100644 --- a/apps/web/src/components/tools/pixelate-settings.tsx +++ b/apps/web/src/components/tools/pixelate-settings.tsx @@ -1,20 +1,181 @@ import { Download } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; -export function PixelateSettings() { +type PixelateMode = "whole" | "selection"; + +export function PixelateSettings({ + onImageOverlay, + onImageStyle, +}: { + onImageOverlay?: (children: React.ReactNode) => void; + onImageStyle?: (style: React.CSSProperties | null) => void; +}) { const { t } = useTranslation(); const { files } = useFileStore(); + const entry = useFileStore((s) => s.entries[s.selectedIndex]); + const blobUrl = entry?.blobUrl; const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = useToolProcessor("pixelate"); const [blockSize, setBlockSize] = useState(12); + const [mode, setMode] = useState("whole"); + + // Natural image dimensions (for converting normalized box to pixel region) + const [dims, setDims] = useState<{ w: number; h: number } | null>(null); + useEffect(() => { + if (!blobUrl) { + setDims(null); + return; + } + const img = new Image(); + img.onload = () => setDims({ w: img.naturalWidth, h: img.naturalHeight }); + img.src = blobUrl; + }, [blobUrl]); + + // Normalized selection box in 0..1 (default centered 40x40%) + const [boxX, setBoxX] = useState(0.3); + const [boxY, setBoxY] = useState(0.3); + const [boxW, setBoxW] = useState(0.4); + const [boxH, setBoxH] = useState(0.4); + + // Refs for latest values (stable pointer-event handlers read from these) + const boxXRef = useRef(boxX); + const boxYRef = useRef(boxY); + const boxWRef = useRef(boxW); + const boxHRef = useRef(boxH); + useEffect(() => { + boxXRef.current = boxX; + }, [boxX]); + useEffect(() => { + boxYRef.current = boxY; + }, [boxY]); + useEffect(() => { + boxWRef.current = boxW; + }, [boxW]); + useEffect(() => { + boxHRef.current = boxH; + }, [boxH]); + + const dragRef = useRef<{ + startX: number; + startY: number; + origX: number; + origY: number; + } | null>(null); + const containerRef = useRef(null); + + // Ref-to-latest for overlay/style callbacks (avoids stale closures) + const onOverlayRef = useRef(onImageOverlay); + useEffect(() => { + onOverlayRef.current = onImageOverlay; + }); + const onStyleRef = useRef(onImageStyle); + useEffect(() => { + onStyleRef.current = onImageStyle; + }); + + // Stable pointer-event handlers (read state from refs, never go stale) + const handlePointerDown = useCallback((e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + dragRef.current = { + startX: e.clientX, + startY: e.clientY, + origX: boxXRef.current, + origY: boxYRef.current, + }; + }, []); + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!dragRef.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const dx = (e.clientX - dragRef.current.startX) / rect.width; + const dy = (e.clientY - dragRef.current.startY) / rect.height; + const nx = Math.min(Math.max(dragRef.current.origX + dx, 0), 1 - boxWRef.current); + const ny = Math.min(Math.max(dragRef.current.origY + dy, 0), 1 - boxHRef.current); + setBoxX(nx); + setBoxY(ny); + }, []); + + const handlePointerUp = useCallback(() => { + dragRef.current = null; + }, []); + + // Clamp box position when width/height sliders change + useEffect(() => { + setBoxX((prev) => Math.min(prev, 1 - boxW)); + setBoxY((prev) => Math.min(prev, 1 - boxH)); + }, [boxW, boxH]); + + // Manage overlay and imageWrapperStyle based on mode + useEffect(() => { + if (mode !== "selection") { + onOverlayRef.current?.(null); + onStyleRef.current?.(null); + return; + } + + // Activate the image wrapper div so overlay children render + onStyleRef.current?.({}); + + const overlay = ( +
{ + containerRef.current = el; + }} + style={{ + position: "absolute" as const, + inset: 0, + zIndex: 10, + pointerEvents: "none" as const, + }} + > +
+
+ ); + + onOverlayRef.current?.(overlay); + + return () => { + onOverlayRef.current?.(null); + onStyleRef.current?.(null); + }; + }, [mode, boxX, boxY, boxW, boxH, handlePointerDown, handlePointerMove, handlePointerUp]); const handleProcess = () => { - const settings = { blockSize }; + const settings: Record = { blockSize }; + if (mode === "selection" && dims) { + const W = dims.w; + const H = dims.h; + const left = Math.round(boxX * W); + const top = Math.round(boxY * H); + const width = Math.max(1, Math.min(Math.round(boxW * W), W - left)); + const height = Math.max(1, Math.min(Math.round(boxH * H), H - top)); + settings.region = { left, top, width, height }; + } if (files.length > 1) { processAllFiles(files, settings); } else { @@ -32,6 +193,37 @@ export function PixelateSettings() { return (
+ {/* Mode toggle */} +
+ Mode +
+ + +
+
+ {/* Block Size */}
@@ -49,11 +241,60 @@ export function PixelateSettings() { onChange={(e) => setBlockSize(Number(e.target.value))} className="w-full mt-1" /> -

- Applies pixelation to the full image -

+ {mode === "whole" && ( +

+ Applies pixelation to the full image +

+ )}
+ {/* Selection region controls */} + {mode === "selection" && ( +
+
+
+ + {Math.round(boxW * 100)}% +
+ setBoxW(Number(e.target.value) / 100)} + className="w-full mt-1" + /> +
+
+
+ + {Math.round(boxH * 100)}% +
+ setBoxH(Number(e.target.value) / 100)} + className="w-full mt-1" + /> +
+ {hasFile && ( +

+ Drag the selection box on the image to reposition +

+ )} +
+ )} + {error &&

{error}

} {processing ? ( diff --git a/apps/web/src/components/tools/sprite-sheet-settings.tsx b/apps/web/src/components/tools/sprite-sheet-settings.tsx index 3ab20687..0a0491e7 100644 --- a/apps/web/src/components/tools/sprite-sheet-settings.tsx +++ b/apps/web/src/components/tools/sprite-sheet-settings.tsx @@ -1,27 +1,40 @@ -import { Download } from "lucide-react"; +import { Check, ClipboardCopy, Download } from "lucide-react"; import { useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { copyToClipboard } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; +type OutputFormat = "png" | "webp" | "jpeg"; + +interface Frame { + index: number; + left: number; + top: number; + width: number; + height: number; +} + export function SpriteSheetSettings() { const { t } = useTranslation(); const { files } = useFileStore(); - const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = + const { processFiles, processing, error, downloadUrl, progress, resultPayload } = useToolProcessor("sprite-sheet"); const [columns, setColumns] = useState(4); const [padding, setPadding] = useState(0); const [background, setBackground] = useState("#ffffff"); + const [format, setFormat] = useState("png"); + const [quality, setQuality] = useState(90); + const [copiedExport, setCopiedExport] = useState<"css" | "json" | null>(null); const handleProcess = () => { - const settings = { columns, padding, background }; - if (files.length > 1) { - processAllFiles(files, settings); - } else { - processFiles(files, settings); - } + const settings = { columns, padding, background, format, quality }; + // sprite-sheet packs every image into ONE sheet, so all files go in a + // single request. It is a MULTI_FILE tool, so processFiles appends them + // all; processAllFiles would wrongly fan out to the per-file batch route. + processFiles(files, settings); }; const hasFile = files.length > 0; @@ -94,6 +107,47 @@ export function SpriteSheetSettings() {
+ {/* Format */} +
+ Format +
+ {(["png", "webp", "jpeg"] as const).map((f) => ( + + ))} +
+
+ + {/* Quality (webp/jpeg only) */} + {format !== "png" && ( +
+
+ Quality + {quality} +
+ setQuality(Number(e.target.value))} + className="w-full mt-1" + data-testid="sprite-sheet-quality" + /> +
+ )} + {error &&

{error}

} {processing ? ( @@ -127,6 +181,104 @@ export function SpriteSheetSettings() { {t.common.download} )} + + {/* Coordinate map output */} + {Array.isArray(resultPayload?.frames) && ( + + )} ); } + +function SpriteOutput({ + payload, + format, + copiedExport, + setCopiedExport, +}: { + payload: Record; + format: OutputFormat; + copiedExport: "css" | "json" | null; + setCopiedExport: (v: "css" | "json" | null) => void; +}) { + const frames = payload.frames as Frame[]; + const cols = payload.cols as number; + const rows = payload.rows as number; + const cellWidth = payload.cellWidth as number; + const cellHeight = payload.cellHeight as number; + const canvasWidth = payload.canvasWidth as number; + const canvasHeight = payload.canvasHeight as number; + + const ext = format === "jpeg" ? "jpg" : format; + + const copyCss = async () => { + const base = `.sprite {\n background-image: url('sprite.${ext}');\n background-repeat: no-repeat;\n display: inline-block;\n}`; + const rules = frames + .map( + (f) => + `.sprite-${f.index} {\n width: ${f.width}px;\n height: ${f.height}px;\n background-position: -${f.left}px -${f.top}px;\n}`, + ) + .join("\n"); + const ok = await copyToClipboard(`${base}\n${rules}`); + if (ok) { + setCopiedExport("css"); + setTimeout(() => setCopiedExport(null), 1500); + } + }; + + const copyJson = async () => { + const ok = await copyToClipboard( + JSON.stringify( + { frames, cols, rows, cellWidth, cellHeight, canvasWidth, canvasHeight }, + null, + 2, + ), + ); + if (ok) { + setCopiedExport("json"); + setTimeout(() => setCopiedExport(null), 1500); + } + }; + + return ( +
+

+ {cols} x {rows} grid · {cellWidth} x {cellHeight}px cells · {canvasWidth} x {canvasHeight}px + canvas +

+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/vignette-settings.tsx b/apps/web/src/components/tools/vignette-settings.tsx index e90d3df2..f890b7fb 100644 --- a/apps/web/src/components/tools/vignette-settings.tsx +++ b/apps/web/src/components/tools/vignette-settings.tsx @@ -1,11 +1,17 @@ import { Download } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; -export function VignetteSettings() { +export function VignetteSettings({ + onImageStyle, + onImageOverlay, +}: { + onImageStyle?: (style: React.CSSProperties | null) => void; + onImageOverlay?: (node: React.ReactNode) => void; +}) { const { t } = useTranslation(); const { files } = useFileStore(); const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = @@ -13,9 +19,59 @@ export function VignetteSettings() { const [strength, setStrength] = useState(0.5); const [color, setColor] = useState("#000000"); + const [radius, setRadius] = useState(70); + const [softness, setSoftness] = useState(50); + const [roundness, setRoundness] = useState(100); + const [centerX, setCenterX] = useState(50); + const [centerY, setCenterY] = useState(50); + + // Stable refs for the preview callbacks to avoid re-render loops + const onOverlayRef = useRef(onImageOverlay); + useEffect(() => { + onOverlayRef.current = onImageOverlay; + }); + const onStyleRef = useRef(onImageStyle); + useEffect(() => { + onStyleRef.current = onImageStyle; + }); + + // Live preview overlay via onImageOverlay + useEffect(() => { + const outerR = radius / 100; + const innerStop = Math.max(0, Math.min(1, outerR * (1 - softness / 100))); + const innerPct = (innerStop * 100).toFixed(1); + + const shape = roundness === 100 ? "circle" : "ellipse"; + const gradientColor = `${color}`; + + const bg = `radial-gradient(${shape} at ${centerX}% ${centerY}%, transparent ${innerPct}%, ${gradientColor} ${(outerR * 100).toFixed(1)}%)`; + + const overlay = ( +
+ ); + + // onImageStyle activates the wrapper branch in image-viewer so the overlay + // child actually mounts; without it the overlay node is never rendered. + onStyleRef.current?.({}); + onOverlayRef.current?.(overlay); + return () => { + onStyleRef.current?.(null); + onOverlayRef.current?.(null); + }; + }, [strength, color, radius, softness, roundness, centerX, centerY]); const handleProcess = () => { - const settings = { strength, color }; + const settings = { strength, color, radius, softness, roundness, centerX, centerY }; if (files.length > 1) { processAllFiles(files, settings); } else { @@ -53,6 +109,106 @@ export function VignetteSettings() { />
+ {/* Radius */} +
+
+ + {radius}% +
+ setRadius(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Softness */} +
+
+ + {softness}% +
+ setSoftness(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Roundness */} +
+
+ + {roundness}% +
+ setRoundness(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Center X */} +
+
+ + {centerX}% +
+ setCenterX(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Center Y */} +
+
+ + {centerY}% +
+ setCenterY(Number(e.target.value))} + className="w-full mt-1" + /> +
+ {/* Vignette Color */}