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:
stirling-image
2026-04-06 12:53:53 +08:00
committed by GitHub
co-authored by Siddharth Kumar Sah
parent fe80287cb4
commit 5d8556254f
9 changed files with 536 additions and 97 deletions
@@ -31,6 +31,7 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
const [padToSquare, setPadToSquare] = useState(false);
const [padColor, setPadColor] = useState("#ffffff");
const [targetSize, setTargetSize] = useState("1000");
const [quality, setQuality] = useState(95);
const emit = (overrides: Record<string, unknown> = {}) => {
if (mode === "content") {
@@ -39,6 +40,7 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
threshold,
padToSquare,
padColor,
quality,
...(padToSquare ? { targetSize: Number(targetSize) } : {}),
...overrides,
});
@@ -47,6 +49,7 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
mode: "attention",
width: Number(width),
height: Number(height),
quality,
...overrides,
});
}
@@ -55,9 +58,9 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
const handleModeChange = (m: Mode) => {
setMode(m);
if (m === "content") {
onChange?.({ mode: "content", threshold, padToSquare, padColor });
onChange?.({ mode: "content", threshold, padToSquare, padColor, quality });
} else {
onChange?.({ mode: "attention", width: Number(width), height: Number(height) });
onChange?.({ mode: "attention", width: Number(width), height: Number(height), quality });
}
};
@@ -257,6 +260,32 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
</p>
</>
)}
{/* Quality slider */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="smart-crop-quality" className="text-xs text-muted-foreground">
Output Quality
</label>
<span className="text-xs text-muted-foreground tabular-nums">{quality}%</span>
</div>
<input
id="smart-crop-quality"
type="range"
min={1}
max={100}
value={quality}
onChange={(e) => {
const v = Number(e.target.value);
setQuality(v);
emit({ quality: v });
}}
className="w-full mt-1"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">
For JPEG and WebP outputs. PNG is always lossless.
</p>
</div>
</div>
);
}
@@ -271,6 +300,7 @@ export function SmartCropSettings() {
threshold: 30,
padToSquare: false,
padColor: "#ffffff",
quality: 95,
});
const handleProcess = () => {
+10 -12
View File
@@ -323,25 +323,23 @@ export function useToolProcessor(toolId: string) {
const zipBuffer = new Uint8Array((await zipBlob.arrayBuffer()) as ArrayBuffer);
const extracted = unzipSync(zipBuffer);
const fileOrder = (response.headers.get("X-File-Order")?.split(",") ?? []).map(
decodeURIComponent,
);
const entries = useFileStore.getState().entries;
const extractedNames = Object.keys(extracted);
let fileResults: Record<string, string> = {};
try {
fileResults = JSON.parse(response.headers.get("X-File-Results") ?? "{}");
} catch {
// Malformed header - fall back to empty mapping, all entries marked failed
}
for (let i = 0; i < entries.length; i++) {
let zipName: string | undefined;
if (fileOrder[i] && extracted[fileOrder[i]]) {
zipName = fileOrder[i];
} else {
zipName = extractedNames.find((n) => n === entries[i].file.name) ?? extractedNames[i];
}
if (zipName && extracted[zipName]) {
const blob = new Blob([extracted[zipName] as BlobPart]);
const processedName = fileResults[String(i)];
if (processedName && extracted[processedName]) {
const blob = new Blob([extracted[processedName] as BlobPart]);
updateEntry(i, {
processedUrl: URL.createObjectURL(blob),
processedSize: blob.size,
status: "completed",
error: null,
});
} else {
updateEntry(i, { status: "failed", error: "File not found in batch results" });