mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: batch file ordering and format preservation for image tools (#20)
* feat: add resolveOutputFormat utility for input format preservation * fix: preserve file order in batch processing with X-File-Results header Collect all results before streaming the ZIP to guarantee upload order. Replace X-File-Order with index-based X-File-Results header that maps each upload index to its processed filename, handling failures and duplicate filenames correctly. Closes #13 * fix: use X-File-Results for index-based batch file matching The frontend now matches processed files to entries by upload index instead of fragile name/position matching. * feat: preserve input format in smart-crop with quality control Smart crop now outputs in the same format as the input (JPG in, JPG out) instead of always converting to PNG. Adds an optional quality setting (default 95) for lossy formats. Closes #14 * feat: add output quality slider to smart crop settings UI * feat: preserve input format in crop tool * feat: preserve input format in color adjustment tools Applies to brightness-contrast, saturation, color-channels, and color-effects tool routes. * refactor: avoid double encode in smart-crop content mode For the simple trim path (no pad-to-square), chain .toFormat() on the trim pipeline directly instead of creating a second Sharp instance. This eliminates a redundant intermediate encode that degraded quality for lossy formats. Also use trimmed.info dimensions instead of a separate metadata() call for the pad-to-square path. --------- Co-authored-by: Siddharth Kumar Sah <siddharth123sk@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Siddharth Kumar Sah
parent
fe80287cb4
commit
5d8556254f
@@ -10,6 +10,7 @@ import {
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -29,7 +30,6 @@ const settingsSchema = z.object({
|
||||
* Serves tool IDs: brightness-contrast, saturation, color-channels, color-effects
|
||||
*/
|
||||
export function registerColorAdjustments(app: FastifyInstance) {
|
||||
// Register the same handler under all four color-related tool IDs
|
||||
const toolIds = ["brightness-contrast", "saturation", "color-channels", "color-effects"];
|
||||
|
||||
for (const toolId of toolIds) {
|
||||
@@ -37,28 +37,25 @@ export function registerColorAdjustments(app: FastifyInstance) {
|
||||
toolId,
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let image = sharp(inputBuffer);
|
||||
|
||||
// Apply brightness
|
||||
if (settings.brightness !== 0) {
|
||||
image = await adjustBrightness(image, {
|
||||
value: settings.brightness,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply contrast
|
||||
if (settings.contrast !== 0) {
|
||||
image = await adjustContrast(image, { value: settings.contrast });
|
||||
}
|
||||
|
||||
// Apply saturation
|
||||
if (settings.saturation !== 0) {
|
||||
image = await adjustSaturation(image, {
|
||||
value: settings.saturation,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply color channels (only if not default 100/100/100)
|
||||
if (settings.red !== 100 || settings.green !== 100 || settings.blue !== 100) {
|
||||
image = await colorChannels(image, {
|
||||
red: settings.red,
|
||||
@@ -67,7 +64,6 @@ export function registerColorAdjustments(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply effect
|
||||
switch (settings.effect) {
|
||||
case "grayscale":
|
||||
image = await grayscale(image);
|
||||
@@ -80,8 +76,10 @@ export function registerColorAdjustments(app: FastifyInstance) {
|
||||
break;
|
||||
}
|
||||
|
||||
const buffer = await image.toBuffer();
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
const buffer = await image
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { crop } from "@stirling-image/image-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -16,10 +17,13 @@ export function registerCrop(app: FastifyInstance) {
|
||||
toolId: "crop",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const image = sharp(inputBuffer);
|
||||
const result = await crop(image, settings);
|
||||
const buffer = await result.toBuffer();
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
const buffer = await result
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
mode: z.enum(["attention", "content"]).default("attention"),
|
||||
// Attention mode: resize to target dimensions using subject detection
|
||||
width: z.number().int().positive().optional(),
|
||||
height: z.number().int().positive().optional(),
|
||||
// Content mode: trim uniform borders, optionally pad to square
|
||||
threshold: z.number().int().min(0).max(255).default(30),
|
||||
padToSquare: z.boolean().default(false),
|
||||
padColor: z.string().default("#ffffff"),
|
||||
targetSize: z.number().int().positive().optional(),
|
||||
quality: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -26,35 +26,41 @@ export function registerSmartCrop(app: FastifyInstance) {
|
||||
toolId: "smart-crop",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename, settings.quality);
|
||||
let result: Buffer;
|
||||
|
||||
if (settings.mode === "content") {
|
||||
// Crop to content: trim uniform borders
|
||||
const pipeline = sharp(inputBuffer).trim({ threshold: settings.threshold });
|
||||
let trimmed = await pipeline.toBuffer({ resolveWithObject: true });
|
||||
|
||||
if (settings.padToSquare || settings.targetSize) {
|
||||
const meta = await sharp(trimmed.data).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
// Trim first to get dimensions, then pad to square
|
||||
const trimmed = await sharp(inputBuffer)
|
||||
.trim({ threshold: settings.threshold })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const w = trimmed.info.width;
|
||||
const h = trimmed.info.height;
|
||||
const target = settings.targetSize || Math.max(w, h);
|
||||
const padR = Math.round(parseInt(settings.padColor.slice(1, 3), 16));
|
||||
const padG = Math.round(parseInt(settings.padColor.slice(3, 5), 16));
|
||||
const padB = Math.round(parseInt(settings.padColor.slice(5, 7), 16));
|
||||
|
||||
trimmed = await sharp(trimmed.data)
|
||||
const padded = await sharp(trimmed.data)
|
||||
.resize({
|
||||
width: target,
|
||||
height: target,
|
||||
fit: "contain",
|
||||
background: { r: padR, g: padG, b: padB, alpha: 1 },
|
||||
})
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
result = padded;
|
||||
} else {
|
||||
// Simple trim + format in one pass (no intermediate encode)
|
||||
result = await sharp(inputBuffer)
|
||||
.trim({ threshold: settings.threshold })
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
result = trimmed.data;
|
||||
} else {
|
||||
// Attention mode: resize to target using subject detection
|
||||
const w = settings.width ?? 1080;
|
||||
const h = settings.height ?? 1080;
|
||||
result = await sharp(inputBuffer)
|
||||
@@ -62,12 +68,13 @@ export function registerSmartCrop(app: FastifyInstance) {
|
||||
fit: "cover",
|
||||
position: sharp.strategy.attention,
|
||||
})
|
||||
.png()
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_smartcrop.png`;
|
||||
return { buffer: result, filename: outputFilename, contentType: "image/png" };
|
||||
const stem = filename.replace(/\.[^.]+$/, "");
|
||||
const outputFilename = `${stem}_smartcrop.${outputFormat.extension}`;
|
||||
return { buffer: result, filename: outputFilename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user