diff --git a/apps/api/package.json b/apps/api/package.json index efa87e98..153281f9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "@fastify/static": "^8.1.0", "@fastify/swagger": "^9.4.0", "@fastify/swagger-ui": "^5.2.0", + "@stirling-image/ai": "workspace:*", "@stirling-image/image-engine": "workspace:*", "@stirling-image/shared": "workspace:*", "archiver": "^7.0.1", diff --git a/apps/api/src/routes/tools/blur-faces.ts b/apps/api/src/routes/tools/blur-faces.ts new file mode 100644 index 00000000..93e78324 --- /dev/null +++ b/apps/api/src/routes/tools/blur-faces.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join, basename } from "node:path"; +import { blurFaces } from "@stirling-image/ai"; +import { createWorkspace } from "../../lib/workspace.js"; + +/** + * Face detection and blurring route. + * Uses MediaPipe for detection, PIL for blurring. + */ +export function registerBlurFaces(app: FastifyInstance) { + app.post( + "/api/v1/tools/blur-faces", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + let settingsRaw: string | null = null; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + filename = basename(part.filename ?? "image"); + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + try { + const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + // Save input + const inputPath = join(workspacePath, "input", filename); + await writeFile(inputPath, fileBuffer); + + // Process + const result = await blurFaces( + fileBuffer, + join(workspacePath, "output"), + { + blurRadius: settings.blurRadius ?? 30, + sensitivity: settings.sensitivity ?? 0.5, + }, + ); + + // Save output + const outputFilename = + filename.replace(/\.[^.]+$/, "") + "_blurred.png"; + const outputPath = join(workspacePath, "output", outputFilename); + await writeFile(outputPath, result.buffer); + + return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, + originalSize: fileBuffer.length, + processedSize: result.buffer.length, + facesDetected: result.facesDetected, + faces: result.faces, + }); + } catch (err) { + return reply.status(422).send({ + error: "Face blur failed", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); +} diff --git a/apps/api/src/routes/tools/erase-object.ts b/apps/api/src/routes/tools/erase-object.ts new file mode 100644 index 00000000..e594736f --- /dev/null +++ b/apps/api/src/routes/tools/erase-object.ts @@ -0,0 +1,88 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join, basename } from "node:path"; +import { inpaint } from "@stirling-image/ai"; +import { createWorkspace } from "../../lib/workspace.js"; + +/** + * Object eraser / inpainting route. + * Accepts an image and a mask image, erases masked areas. + */ +export function registerEraseObject(app: FastifyInstance) { + app.post( + "/api/v1/tools/erase-object", + async (request: FastifyRequest, reply: FastifyReply) => { + let imageBuffer: Buffer | null = null; + let maskBuffer: Buffer | null = null; + let filename = "image"; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + const buf = Buffer.concat(chunks); + if (part.fieldname === "mask") { + maskBuffer = buf; + } else { + imageBuffer = buf; + filename = basename(part.filename ?? "image"); + } + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!imageBuffer || imageBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + if (!maskBuffer || maskBuffer.length === 0) { + return reply + .status(400) + .send({ error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'" }); + } + + try { + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + // Save input + const inputPath = join(workspacePath, "input", filename); + await writeFile(inputPath, imageBuffer); + + // Process + const resultBuffer = await inpaint( + imageBuffer, + maskBuffer, + join(workspacePath, "output"), + ); + + // Save output + const outputFilename = + filename.replace(/\.[^.]+$/, "") + "_erased.png"; + const outputPath = join(workspacePath, "output", outputFilename); + await writeFile(outputPath, resultBuffer); + + return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, + originalSize: imageBuffer.length, + processedSize: resultBuffer.length, + }); + } catch (err) { + return reply.status(422).send({ + error: "Object erasing failed", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index 4a63b6cc..fe3cc5d8 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -32,6 +32,13 @@ import { registerFavicon } from "./favicon.js"; import { registerImageToPdf } from "./image-to-pdf.js"; // Phase 3: Adjustments extra import { registerReplaceColor } from "./replace-color.js"; +// Phase 4: AI Tools +import { registerRemoveBackground } from "./remove-background.js"; +import { registerUpscale } from "./upscale.js"; +import { registerOcr } from "./ocr.js"; +import { registerBlurFaces } from "./blur-faces.js"; +import { registerEraseObject } from "./erase-object.js"; +import { registerSmartCrop } from "./smart-crop.js"; /** * Registry that imports and registers all tool routes. @@ -79,5 +86,13 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { // Phase 3: Adjustments extra registerReplaceColor(app); - app.log.info("Tool routes registered (26 tools, 29 endpoints)"); + // Phase 4: AI Tools + registerRemoveBackground(app); + registerUpscale(app); + registerOcr(app); + registerBlurFaces(app); + registerEraseObject(app); + registerSmartCrop(app); + + app.log.info("Tool routes registered (32 tools, 35 endpoints)"); } diff --git a/apps/api/src/routes/tools/ocr.ts b/apps/api/src/routes/tools/ocr.ts new file mode 100644 index 00000000..80efa973 --- /dev/null +++ b/apps/api/src/routes/tools/ocr.ts @@ -0,0 +1,68 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { randomUUID } from "node:crypto"; +import { basename } from "node:path"; +import { extractText } from "@stirling-image/ai"; +import { createWorkspace } from "../../lib/workspace.js"; + +/** + * OCR / text extraction route. + * Returns JSON with extracted text rather than an image. + */ +export function registerOcr(app: FastifyInstance) { + app.post( + "/api/v1/tools/ocr", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + let settingsRaw: string | null = null; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + filename = basename(part.filename ?? "image"); + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + try { + const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + const result = await extractText(fileBuffer, workspacePath, { + engine: settings.engine, + language: settings.language, + }); + + return reply.send({ + jobId, + filename, + text: result.text, + engine: result.engine, + }); + } catch (err) { + return reply.status(422).send({ + error: "OCR failed", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); +} diff --git a/apps/api/src/routes/tools/remove-background.ts b/apps/api/src/routes/tools/remove-background.ts new file mode 100644 index 00000000..4333654c --- /dev/null +++ b/apps/api/src/routes/tools/remove-background.ts @@ -0,0 +1,80 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join, basename } from "node:path"; +import { removeBackground } from "@stirling-image/ai"; +import { createWorkspace } from "../../lib/workspace.js"; + +/** + * AI background removal route. + * Uses Python + rembg under the hood. + */ +export function registerRemoveBackground(app: FastifyInstance) { + app.post( + "/api/v1/tools/remove-background", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + let settingsRaw: string | null = null; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + filename = basename(part.filename ?? "image"); + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + try { + const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + // Save input + const inputPath = join(workspacePath, "input", filename); + await writeFile(inputPath, fileBuffer); + + // Process + const resultBuffer = await removeBackground( + fileBuffer, + join(workspacePath, "output"), + { model: settings.model }, + ); + + // Save output + const outputFilename = filename.replace(/\.[^.]+$/, "") + "_nobg.png"; + const outputPath = join(workspacePath, "output", outputFilename); + await writeFile(outputPath, resultBuffer); + + return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, + originalSize: fileBuffer.length, + processedSize: resultBuffer.length, + }); + } catch (err) { + return reply.status(422).send({ + error: "Background removal failed", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); +} diff --git a/apps/api/src/routes/tools/smart-crop.ts b/apps/api/src/routes/tools/smart-crop.ts new file mode 100644 index 00000000..3998366a --- /dev/null +++ b/apps/api/src/routes/tools/smart-crop.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import sharp from "sharp"; +import type { FastifyInstance } from "fastify"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + width: z.number().int().positive(), + height: z.number().int().positive(), +}); + +/** + * Smart crop using Sharp's attention-based strategy. + * Uses entropy/saliency detection to find the most interesting region. + * No Python needed. + */ +export function registerSmartCrop(app: FastifyInstance) { + createToolRoute(app, { + toolId: "smart-crop", + settingsSchema, + process: async (inputBuffer, settings, filename) => { + const result = await sharp(inputBuffer) + .resize(settings.width, settings.height, { + fit: "cover", + position: sharp.strategy.attention, + }) + .png() + .toBuffer(); + + const outputFilename = filename.replace(/\.[^.]+$/, "") + "_smartcrop.png"; + return { buffer: result, filename: outputFilename, contentType: "image/png" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/upscale.ts b/apps/api/src/routes/tools/upscale.ts new file mode 100644 index 00000000..a3e2052a --- /dev/null +++ b/apps/api/src/routes/tools/upscale.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join, basename } from "node:path"; +import { upscale } from "@stirling-image/ai"; +import { createWorkspace } from "../../lib/workspace.js"; + +/** + * AI image upscaling route. + * Uses Real-ESRGAN when available, falls back to Lanczos. + */ +export function registerUpscale(app: FastifyInstance) { + app.post( + "/api/v1/tools/upscale", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + let settingsRaw: string | null = null; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + filename = basename(part.filename ?? "image"); + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + try { + const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + const scale = Number(settings.scale) || 2; + + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + // Save input + const inputPath = join(workspacePath, "input", filename); + await writeFile(inputPath, fileBuffer); + + // Process + const result = await upscale( + fileBuffer, + join(workspacePath, "output"), + { scale }, + ); + + // Save output + const outputFilename = + filename.replace(/\.[^.]+$/, "") + `_${scale}x.png`; + const outputPath = join(workspacePath, "output", outputFilename); + await writeFile(outputPath, result.buffer); + + return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, + originalSize: fileBuffer.length, + processedSize: result.buffer.length, + width: result.width, + height: result.height, + method: result.method, + }); + } catch (err) { + return reply.status(422).send({ + error: "Upscaling failed", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); +} diff --git a/apps/web/src/components/tools/blur-faces-settings.tsx b/apps/web/src/components/tools/blur-faces-settings.tsx new file mode 100644 index 00000000..4d380c27 --- /dev/null +++ b/apps/web/src/components/tools/blur-faces-settings.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { useFileStore } from "@/stores/file-store"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { Download, Loader2 } from "lucide-react"; + +export function BlurFacesSettings() { + const { files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, originalSize, processedSize } = + useToolProcessor("blur-faces"); + + const [blurRadius, setBlurRadius] = useState(30); + const [sensitivity, setSensitivity] = useState(50); + + const handleProcess = () => { + processFiles(files, { + blurRadius, + sensitivity: sensitivity / 100, + }); + }; + + const hasFile = files.length > 0; + + return ( +
+ {/* Blur radius */} +
+
+ + {blurRadius} +
+ setBlurRadius(Number(e.target.value))} + className="w-full mt-1" + /> +
+ Light + Heavy +
+
+ + {/* Sensitivity */} +
+
+ + {sensitivity}% +
+ setSensitivity(Number(e.target.value))} + className="w-full mt-1" + /> +
+ More faces + Fewer false positives +
+
+ + {/* Info */} +

+ Uses MediaPipe for face detection. Automatically detects and blurs all faces in the image. +

+ + {/* Error */} + {error &&

{error}

} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {/* Process button */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/erase-object-settings.tsx b/apps/web/src/components/tools/erase-object-settings.tsx new file mode 100644 index 00000000..4d929d0f --- /dev/null +++ b/apps/web/src/components/tools/erase-object-settings.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { useFileStore } from "@/stores/file-store"; +import { Download, Loader2, Upload } from "lucide-react"; + +function getToken(): string { + return localStorage.getItem("stirling-token") || ""; +} + +export function EraseObjectSettings() { + const { files, processing, error, setProcessing, setError } = useFileStore(); + + const [maskFile, setMaskFile] = useState(null); + const [downloadUrl, setDownloadUrl] = useState(null); + const [originalSize, setOriginalSize] = useState(null); + const [processedSize, setProcessedSize] = useState(null); + + const handleMaskSelect = (e: React.ChangeEvent) => { + const selected = e.target.files?.[0]; + if (selected) setMaskFile(selected); + }; + + const handleProcess = async () => { + if (files.length === 0 || !maskFile) return; + + setProcessing(true); + setError(null); + setDownloadUrl(null); + + try { + const formData = new FormData(); + formData.append("file", files[0]); + formData.append("mask", maskFile); + + const res = await fetch("/api/v1/tools/erase-object", { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || body.details || `Failed: ${res.status}`); + } + + const data = await res.json(); + setDownloadUrl(data.downloadUrl); + setOriginalSize(data.originalSize); + setProcessedSize(data.processedSize); + } catch (err) { + setError(err instanceof Error ? err.message : "Object erasing failed"); + } finally { + setProcessing(false); + } + }; + + const hasFile = files.length > 0; + + return ( +
+ {/* Mask upload */} +
+ +

+ Upload a black & white mask where white areas will be erased. Create the mask in any image editor. +

+ +
+ + {/* Info */} +
+

How to create a mask:

+
    +
  1. Open your image in any editor
  2. +
  3. Paint white over areas to erase
  4. +
  5. Keep the rest black
  6. +
  7. Export as PNG and upload here
  8. +
+
+ + {/* Error */} + {error &&

{error}

} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {/* Process button */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/ocr-settings.tsx b/apps/web/src/components/tools/ocr-settings.tsx new file mode 100644 index 00000000..40d60c15 --- /dev/null +++ b/apps/web/src/components/tools/ocr-settings.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { useFileStore } from "@/stores/file-store"; +import { Loader2, Copy, Check } from "lucide-react"; + +function getToken(): string { + return localStorage.getItem("stirling-token") || ""; +} + +type OcrEngine = "tesseract" | "paddleocr"; + +const LANGUAGES = [ + { code: "en", label: "English" }, + { code: "de", label: "German" }, + { code: "fr", label: "French" }, + { code: "es", label: "Spanish" }, + { code: "zh", label: "Chinese" }, + { code: "ja", label: "Japanese" }, + { code: "ko", label: "Korean" }, +]; + +export function OcrSettings() { + const { files, processing, error, setProcessing, setError } = useFileStore(); + + const [engine, setEngine] = useState("tesseract"); + const [language, setLanguage] = useState("en"); + const [text, setText] = useState(null); + const [detectedEngine, setDetectedEngine] = useState(""); + const [copied, setCopied] = useState(false); + + const handleProcess = async () => { + if (files.length === 0) return; + + setProcessing(true); + setError(null); + setText(null); + + try { + const formData = new FormData(); + formData.append("file", files[0]); + formData.append("settings", JSON.stringify({ engine, language })); + + const res = await fetch("/api/v1/tools/ocr", { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || body.details || `Failed: ${res.status}`); + } + + const data = await res.json(); + setText(data.text || ""); + setDetectedEngine(data.engine || engine); + } catch (err) { + setError(err instanceof Error ? err.message : "OCR failed"); + } finally { + setProcessing(false); + } + }; + + const handleCopy = async () => { + if (text) { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const hasFile = files.length > 0; + + return ( +
+ {/* Engine selector */} +
+ +
+ + +
+
+ + {/* Language selector */} +
+ + +
+ + {/* Error */} + {error &&

{error}

} + + {/* Process button */} + + + {/* Result */} + {text !== null && ( +
+
+ + +
+