mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(erase-object): overhaul object eraser with LaMa inpainting improvements
Update erase-object pipeline, eraser canvas, and inpainting Python script. Add LaMa model download script and update Dockerfile for model support. Update multi-file tool routes for consistency.
This commit is contained in:
@@ -13,6 +13,7 @@ import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { autoOrient } from "../lib/auto-orient.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
@@ -107,6 +108,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize EXIF orientation before passing to pipeline steps
|
||||
fileBuffer = await autoOrient(fileBuffer);
|
||||
|
||||
// Parse and validate the pipeline definition
|
||||
if (!pipelineRaw) {
|
||||
return reply.status(400).send({ error: "No pipeline definition provided" });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
@@ -65,7 +66,7 @@ export function registerCollage(app: FastifyInstance) {
|
||||
.status(400)
|
||||
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
|
||||
}
|
||||
file.buffer = await ensureSharpCompat(file.buffer);
|
||||
file.buffer = await autoOrient(await ensureSharpCompat(file.buffer));
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
@@ -42,9 +43,9 @@ export function registerCompare(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode HEIC/HEIF if needed
|
||||
bufferA = await ensureSharpCompat(bufferA);
|
||||
bufferB = await ensureSharpCompat(bufferB);
|
||||
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
|
||||
bufferA = await autoOrient(await ensureSharpCompat(bufferA));
|
||||
bufferB = await autoOrient(await ensureSharpCompat(bufferB));
|
||||
|
||||
// Normalize both to same size for comparison
|
||||
const metaA = await sharp(bufferA).metadata();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
@@ -81,9 +82,9 @@ export function registerCompose(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode HEIC/HEIF if needed
|
||||
baseBuffer = await ensureSharpCompat(baseBuffer);
|
||||
overlayBuffer = await ensureSharpCompat(overlayBuffer);
|
||||
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
|
||||
baseBuffer = await autoOrient(await ensureSharpCompat(baseBuffer));
|
||||
overlayBuffer = await autoOrient(await ensureSharpCompat(overlayBuffer));
|
||||
|
||||
// Apply opacity to overlay if needed
|
||||
let processedOverlay = overlayBuffer;
|
||||
|
||||
@@ -3,13 +3,30 @@ import { writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { inpaint } from "@stirling-image/ai";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { updateSingleFileProgress } from "../progress.js";
|
||||
|
||||
const EXT_MAP: Record<string, string> = {
|
||||
jpeg: "jpg",
|
||||
jpg: "jpg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
tiff: "tiff",
|
||||
gif: "gif",
|
||||
avif: "avif",
|
||||
heic: "heic",
|
||||
heif: "heif",
|
||||
};
|
||||
|
||||
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
|
||||
|
||||
/**
|
||||
* Object eraser / inpainting route.
|
||||
* Accepts an image and a mask image, erases masked areas.
|
||||
* Accepts an image and a mask image, erases masked areas using LaMa.
|
||||
*/
|
||||
export function registerEraseObject(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/erase-object", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
@@ -17,6 +34,8 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
let maskBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let clientJobId: string | null = null;
|
||||
let format = "png";
|
||||
let quality = 95;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
@@ -35,6 +54,10 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "clientJobId") {
|
||||
clientJobId = part.value as string;
|
||||
} else if (part.fieldname === "format") {
|
||||
format = (part.value as string) || "png";
|
||||
} else if (part.fieldname === "quality") {
|
||||
quality = Number(part.value) || 95;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -64,9 +87,23 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
|
||||
try {
|
||||
request.log.info(
|
||||
{ toolId: "erase-object", imageSize: imageBuffer.length, maskSize: maskBuffer.length },
|
||||
{
|
||||
toolId: "erase-object",
|
||||
imageSize: imageBuffer.length,
|
||||
maskSize: maskBuffer.length,
|
||||
format,
|
||||
},
|
||||
"Starting object erasure",
|
||||
);
|
||||
|
||||
// Decode HEIC/HEIF input via system decoder
|
||||
if (imageValidation.format === "heif") {
|
||||
imageBuffer = await decodeHeic(imageBuffer);
|
||||
}
|
||||
|
||||
// Auto-orient to fix EXIF rotation
|
||||
imageBuffer = await autoOrient(imageBuffer);
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
|
||||
@@ -94,10 +131,58 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
onProgress,
|
||||
);
|
||||
|
||||
// Convert to the requested output format using Sharp
|
||||
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
|
||||
let outputBuffer: Buffer;
|
||||
let finalFormat = format;
|
||||
|
||||
if (needsNodeConversion) {
|
||||
if (format === "heic" || format === "heif") {
|
||||
outputBuffer = await encodeHeic(resultBuffer, quality);
|
||||
finalFormat = format;
|
||||
} else {
|
||||
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
|
||||
finalFormat = "avif";
|
||||
}
|
||||
} else if (format === "jpg" || format === "jpeg") {
|
||||
outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer();
|
||||
finalFormat = "jpg";
|
||||
} else if (format === "webp") {
|
||||
outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer();
|
||||
finalFormat = "webp";
|
||||
} else if (format === "tiff") {
|
||||
outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer();
|
||||
finalFormat = "tiff";
|
||||
} else if (format === "gif") {
|
||||
outputBuffer = await sharp(resultBuffer).gif().toBuffer();
|
||||
finalFormat = "gif";
|
||||
} else {
|
||||
outputBuffer = resultBuffer;
|
||||
finalFormat = "png";
|
||||
}
|
||||
|
||||
// Save output
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_erased.png`;
|
||||
const ext = EXT_MAP[finalFormat] || "png";
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_erased.${ext}`;
|
||||
const outputPath = join(workspacePath, "output", outputFilename);
|
||||
await writeFile(outputPath, resultBuffer);
|
||||
await writeFile(outputPath, outputBuffer);
|
||||
|
||||
// Generate browser-compatible preview for non-previewable formats
|
||||
let previewUrl: string | undefined;
|
||||
if (!BROWSER_PREVIEWABLE.has(finalFormat)) {
|
||||
try {
|
||||
const previewInput =
|
||||
finalFormat === "heic" || finalFormat === "heif"
|
||||
? await decodeHeic(outputBuffer)
|
||||
: outputBuffer;
|
||||
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
|
||||
const previewPath = join(workspacePath, "output", "preview.webp");
|
||||
await writeFile(previewPath, previewBuffer);
|
||||
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
||||
} catch {
|
||||
// Non-fatal - frontend will show fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (clientJobId) {
|
||||
updateSingleFileProgress({
|
||||
@@ -110,8 +195,9 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
previewUrl,
|
||||
originalSize: imageBuffer.length,
|
||||
processedSize: resultBuffer.length,
|
||||
processedSize: outputBuffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: "erase-object" }, "Object erasing failed");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { basename, extname } from "node:path";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
|
||||
const FAVICON_SIZES = [
|
||||
@@ -62,8 +63,8 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
archive.pipe(reply.raw);
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
// Decode HEIC/HEIF if needed
|
||||
const decoded = await ensureSharpCompat(file.buffer);
|
||||
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
|
||||
const decoded = await autoOrient(await ensureSharpCompat(file.buffer));
|
||||
const stem = basename(file.filename, extname(file.filename));
|
||||
// Single file: flat structure. Multiple files: per-image folders.
|
||||
const prefix = isSingleFile ? "" : `${stem}/`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { basename } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
|
||||
/**
|
||||
@@ -67,9 +68,9 @@ export function registerFindDuplicates(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode HEIC/HEIF if needed
|
||||
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
|
||||
for (const file of files) {
|
||||
file.buffer = await ensureSharpCompat(file.buffer);
|
||||
file.buffer = await autoOrient(await ensureSharpCompat(file.buffer));
|
||||
}
|
||||
|
||||
// Compute hashes for all images
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import PDFDocument from "pdfkit";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
@@ -96,8 +97,8 @@ export function registerImageToPdf(app: FastifyInstance) {
|
||||
for (const file of files) {
|
||||
doc.addPage({ size: [pageW, pageH], margin });
|
||||
|
||||
// Decode HEIC/HEIF if needed, then convert to PNG for PDFKit compatibility
|
||||
const compatBuffer = await ensureSharpCompat(file.buffer);
|
||||
// Decode HEIC/HEIF if needed, normalize EXIF orientation, then convert to PNG for PDFKit
|
||||
const compatBuffer = await autoOrient(await ensureSharpCompat(file.buffer));
|
||||
const pngBuffer = await sharp(compatBuffer).png().toBuffer();
|
||||
|
||||
const meta = await sharp(pngBuffer).metadata();
|
||||
|
||||
@@ -4,6 +4,7 @@ import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -58,8 +59,8 @@ export function registerSplit(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode HEIC/HEIF if needed
|
||||
fileBuffer = await ensureSharpCompat(fileBuffer);
|
||||
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
|
||||
fileBuffer = await autoOrient(await ensureSharpCompat(fileBuffer));
|
||||
|
||||
const metadata = await sharp(fileBuffer).metadata();
|
||||
const fullW = metadata.width ?? 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
@@ -72,7 +73,7 @@ export function registerStitch(app: FastifyInstance) {
|
||||
.status(400)
|
||||
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
|
||||
}
|
||||
file.buffer = await ensureSharpCompat(file.buffer);
|
||||
file.buffer = await autoOrient(await ensureSharpCompat(file.buffer));
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import potrace from "potrace";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
@@ -79,8 +80,8 @@ export function registerVectorize(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode HEIC/HEIF if needed
|
||||
fileBuffer = await ensureSharpCompat(fileBuffer);
|
||||
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
|
||||
fileBuffer = await autoOrient(await ensureSharpCompat(fileBuffer));
|
||||
|
||||
// Convert to BMP-compatible format for potrace (PNG)
|
||||
const pngBuffer = await sharp(fileBuffer).grayscale().png().toBuffer();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -68,9 +69,9 @@ export function registerWatermarkImage(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode HEIC/HEIF if needed
|
||||
mainBuffer = await ensureSharpCompat(mainBuffer);
|
||||
watermarkBuffer = await ensureSharpCompat(watermarkBuffer);
|
||||
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
|
||||
mainBuffer = await autoOrient(await ensureSharpCompat(mainBuffer));
|
||||
watermarkBuffer = await autoOrient(await ensureSharpCompat(watermarkBuffer));
|
||||
|
||||
const mainImage = sharp(mainBuffer);
|
||||
const mainMeta = await mainImage.metadata();
|
||||
|
||||
Reference in New Issue
Block a user