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 */}