mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #28 from stirling-image/feat/content-aware-resize
feat: add content-aware resize (seam carving) to resize tool
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import { seamCarve } from "@stirling-image/ai";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
|
/** Content-aware resize (seam carving) route. */
|
||||||
|
export function registerContentAwareResize(app: FastifyInstance) {
|
||||||
|
app.post(
|
||||||
|
"/api/v1/tools/content-aware-resize",
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
let fileBuffer: Buffer | null = null;
|
||||||
|
let filename = "image";
|
||||||
|
let settingsRaw: string | null = null;
|
||||||
|
let clientJobId: 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;
|
||||||
|
} else if (part.fieldname === "clientJobId") {
|
||||||
|
clientJobId = 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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await validateImageBuffer(fileBuffer);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
toolId: "content-aware-resize",
|
||||||
|
imageSize: fileBuffer.length,
|
||||||
|
width: settings.width,
|
||||||
|
height: settings.height,
|
||||||
|
protectFaces: settings.protectFaces,
|
||||||
|
},
|
||||||
|
"Starting content-aware resize",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-orient to fix EXIF rotation before seam carving
|
||||||
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
|
const jobId = randomUUID();
|
||||||
|
const workspacePath = await createWorkspace(jobId);
|
||||||
|
|
||||||
|
// Save input
|
||||||
|
const inputPath = join(workspacePath, "input", filename);
|
||||||
|
await writeFile(inputPath, fileBuffer);
|
||||||
|
|
||||||
|
// Process
|
||||||
|
const jobIdForProgress = clientJobId;
|
||||||
|
const onProgress = jobIdForProgress
|
||||||
|
? (percent: number, stage: string) => {
|
||||||
|
updateSingleFileProgress({
|
||||||
|
jobId: jobIdForProgress,
|
||||||
|
phase: "processing",
|
||||||
|
stage,
|
||||||
|
percent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const result = await seamCarve(
|
||||||
|
fileBuffer,
|
||||||
|
join(workspacePath, "output"),
|
||||||
|
{
|
||||||
|
width: settings.width,
|
||||||
|
height: settings.height,
|
||||||
|
protectFaces: settings.protectFaces ?? true,
|
||||||
|
},
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save output
|
||||||
|
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`;
|
||||||
|
const outputPath = join(workspacePath, "output", outputFilename);
|
||||||
|
await writeFile(outputPath, result.buffer);
|
||||||
|
|
||||||
|
if (clientJobId) {
|
||||||
|
updateSingleFileProgress({
|
||||||
|
jobId: clientJobId,
|
||||||
|
phase: "complete",
|
||||||
|
percent: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
jobId,
|
||||||
|
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||||
|
originalSize: fileBuffer.length,
|
||||||
|
processedSize: result.buffer.length,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, toolId: "content-aware-resize" }, "Content-aware resize failed");
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Content-aware resize failed",
|
||||||
|
details: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register in the pipeline/batch registry so this tool can be used
|
||||||
|
// as a step in automation pipelines (without progress callbacks).
|
||||||
|
registerToolProcessFn({
|
||||||
|
toolId: "content-aware-resize",
|
||||||
|
settingsSchema: z.object({
|
||||||
|
width: z.number().positive().optional(),
|
||||||
|
height: z.number().positive().optional(),
|
||||||
|
protectFaces: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
process: async (inputBuffer, settings, filename) => {
|
||||||
|
const s = settings as { width?: number; height?: number; protectFaces?: boolean };
|
||||||
|
const orientedBuffer = await autoOrient(inputBuffer);
|
||||||
|
const jobId = randomUUID();
|
||||||
|
const workspacePath = await createWorkspace(jobId);
|
||||||
|
const result = await seamCarve(orientedBuffer, join(workspacePath, "output"), {
|
||||||
|
width: s.width,
|
||||||
|
height: s.height,
|
||||||
|
protectFaces: s.protectFaces ?? true,
|
||||||
|
});
|
||||||
|
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`;
|
||||||
|
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { registerColorPalette } from "./color-palette.js";
|
|||||||
import { registerCompare } from "./compare.js";
|
import { registerCompare } from "./compare.js";
|
||||||
import { registerCompose } from "./compose.js";
|
import { registerCompose } from "./compose.js";
|
||||||
import { registerCompress } from "./compress.js";
|
import { registerCompress } from "./compress.js";
|
||||||
|
import { registerContentAwareResize } from "./content-aware-resize.js";
|
||||||
import { registerConvert } from "./convert.js";
|
import { registerConvert } from "./convert.js";
|
||||||
import { registerCrop } from "./crop.js";
|
import { registerCrop } from "./crop.js";
|
||||||
import { registerEditMetadata } from "./edit-metadata.js";
|
import { registerEditMetadata } from "./edit-metadata.js";
|
||||||
@@ -126,6 +127,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
{ id: "blur-faces", register: registerBlurFaces },
|
{ id: "blur-faces", register: registerBlurFaces },
|
||||||
{ id: "erase-object", register: registerEraseObject },
|
{ id: "erase-object", register: registerEraseObject },
|
||||||
{ id: "smart-crop", register: registerSmartCrop },
|
{ id: "smart-crop", register: registerSmartCrop },
|
||||||
|
{ id: "content-aware-resize", register: registerContentAwareResize },
|
||||||
];
|
];
|
||||||
|
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
||||||
import { Download, Link, Unlink } from "lucide-react";
|
import { Download, Link, Unlink } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
@@ -30,6 +30,8 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
|||||||
const [fit, setFit] = useState<FitMode>("cover");
|
const [fit, setFit] = useState<FitMode>("cover");
|
||||||
const [lockAspect, setLockAspect] = useState(true);
|
const [lockAspect, setLockAspect] = useState(true);
|
||||||
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
||||||
|
const [contentAware, setContentAware] = useState(false);
|
||||||
|
const [protectFaces, setProtectFaces] = useState(true);
|
||||||
|
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,7 +40,12 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const settings: Record<string, unknown> = {};
|
const settings: Record<string, unknown> = {};
|
||||||
if (tab === "scale") {
|
if (contentAware) {
|
||||||
|
settings.contentAware = true;
|
||||||
|
if (width) settings.width = Number(width);
|
||||||
|
if (height) settings.height = Number(height);
|
||||||
|
settings.protectFaces = protectFaces;
|
||||||
|
} else if (tab === "scale") {
|
||||||
settings.percentage = Number(percentage);
|
settings.percentage = Number(percentage);
|
||||||
} else {
|
} else {
|
||||||
if (width) settings.width = Number(width);
|
if (width) settings.width = Number(width);
|
||||||
@@ -47,7 +54,7 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
|||||||
settings.withoutEnlargement = withoutEnlargement;
|
settings.withoutEnlargement = withoutEnlargement;
|
||||||
}
|
}
|
||||||
onChangeRef.current?.(settings);
|
onChangeRef.current?.(settings);
|
||||||
}, [tab, width, height, percentage, fit, withoutEnlargement]);
|
}, [tab, width, height, percentage, fit, withoutEnlargement, contentAware, protectFaces]);
|
||||||
|
|
||||||
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
||||||
const key = `${preset.platform}-${preset.name}`;
|
const key = `${preset.platform}-${preset.name}`;
|
||||||
@@ -65,171 +72,222 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
|||||||
const tabClass = (t: ResizeTab) =>
|
const tabClass = (t: ResizeTab) =>
|
||||||
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
|
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
|
||||||
|
|
||||||
|
const dimensionInputs = (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
|
||||||
|
Width (px)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="resize-width"
|
||||||
|
type="number"
|
||||||
|
value={width}
|
||||||
|
onChange={(e) => setWidth(e.target.value)}
|
||||||
|
placeholder="Auto"
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLockAspect(!lockAspect)}
|
||||||
|
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
|
||||||
|
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||||
|
>
|
||||||
|
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
||||||
|
Height (px)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="resize-height"
|
||||||
|
type="number"
|
||||||
|
value={height}
|
||||||
|
onChange={(e) => setHeight(e.target.value)}
|
||||||
|
placeholder="Auto"
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Tab selector */}
|
{/* Content-aware toggle */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-1">
|
<span className="text-sm font-medium text-foreground">Content-aware</span>
|
||||||
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
<button
|
||||||
Custom Size
|
type="button"
|
||||||
</button>
|
role="switch"
|
||||||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
aria-checked={contentAware}
|
||||||
Scale
|
onClick={() => setContentAware(!contentAware)}
|
||||||
</button>
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
contentAware ? "bg-primary" : "bg-muted"
|
||||||
Presets
|
}`}
|
||||||
</button>
|
>
|
||||||
</div>
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
contentAware ? "translate-x-4" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Presets tab */}
|
{/* Content-aware inputs */}
|
||||||
{tab === "presets" && (
|
{contentAware && (
|
||||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
|
<div className="space-y-3">
|
||||||
{platforms.map((platform) => (
|
{dimensionInputs}
|
||||||
<div key={platform}>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
|
{/* Protect faces */}
|
||||||
<div className="space-y-1">
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
|
<input
|
||||||
const key = `${preset.platform}-${preset.name}`;
|
type="checkbox"
|
||||||
const isSelected = selectedPreset === key;
|
checked={protectFaces}
|
||||||
return (
|
onChange={(e) => setProtectFaces(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Protect faces
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Standard resize tabs */}
|
||||||
|
{!contentAware && (
|
||||||
|
<>
|
||||||
|
{/* Tab selector */}
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
||||||
|
Custom Size
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||||||
|
Scale
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab("presets")}
|
||||||
|
className={tabClass("presets")}
|
||||||
|
>
|
||||||
|
Presets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presets tab */}
|
||||||
|
{tab === "presets" && (
|
||||||
|
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
|
||||||
|
{platforms.map((platform) => (
|
||||||
|
<div key={platform}>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
|
||||||
|
const key = `${preset.platform}-${preset.name}`;
|
||||||
|
const isSelected = selectedPreset === key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreset(preset)}
|
||||||
|
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/10 text-foreground"
|
||||||
|
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{preset.name}</span>
|
||||||
|
<span className="text-xs tabular-nums">
|
||||||
|
{preset.width} × {preset.height}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Don't enlarge */}
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={withoutEnlargement}
|
||||||
|
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Don't enlarge
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Size tab */}
|
||||||
|
{tab === "custom" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dimensionInputs}
|
||||||
|
|
||||||
|
{/* Fit mode */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Fit Mode</p>
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={f}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePreset(preset)}
|
onClick={() => setFit(f)}
|
||||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
|
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||||
isSelected
|
|
||||||
? "border-primary bg-primary/10 text-foreground"
|
|
||||||
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span>{preset.name}</span>
|
{FIT_LABELS[f]}
|
||||||
<span className="text-xs tabular-nums">
|
|
||||||
{preset.width} × {preset.height}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Don't enlarge */}
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={withoutEnlargement}
|
||||||
|
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Don't enlarge
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scale tab */}
|
||||||
|
{tab === "scale" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
|
||||||
|
Scale (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="resize-scale"
|
||||||
|
type="number"
|
||||||
|
value={percentage}
|
||||||
|
onChange={(e) => setPercentage(e.target.value)}
|
||||||
|
min={1}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[25, 50, 75].map((pct) => (
|
||||||
|
<button
|
||||||
|
key={pct}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPercentage(String(pct))}
|
||||||
|
className={`flex-1 text-xs py-1.5 rounded ${
|
||||||
|
percentage === String(pct)
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</>
|
||||||
{/* Don't enlarge */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={withoutEnlargement}
|
|
||||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
Don't enlarge
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Custom Size tab */}
|
|
||||||
{tab === "custom" && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
|
|
||||||
Width (px)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="resize-width"
|
|
||||||
type="number"
|
|
||||||
value={width}
|
|
||||||
onChange={(e) => setWidth(e.target.value)}
|
|
||||||
placeholder="Auto"
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLockAspect(!lockAspect)}
|
|
||||||
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
|
|
||||||
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
|
||||||
>
|
|
||||||
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
<div className="flex-1">
|
|
||||||
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
|
||||||
Height (px)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="resize-height"
|
|
||||||
type="number"
|
|
||||||
value={height}
|
|
||||||
onChange={(e) => setHeight(e.target.value)}
|
|
||||||
placeholder="Auto"
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fit mode */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Fit Mode</p>
|
|
||||||
<div className="flex gap-1 mt-1">
|
|
||||||
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFit(f)}
|
|
||||||
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{FIT_LABELS[f]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Don't enlarge */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={withoutEnlargement}
|
|
||||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
Don't enlarge
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scale tab */}
|
|
||||||
{tab === "scale" && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
|
|
||||||
Scale (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="resize-scale"
|
|
||||||
type="number"
|
|
||||||
value={percentage}
|
|
||||||
onChange={(e) => setPercentage(e.target.value)}
|
|
||||||
min={1}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{[25, 50, 75].map((pct) => (
|
|
||||||
<button
|
|
||||||
key={pct}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPercentage(String(pct))}
|
|
||||||
className={`flex-1 text-xs py-1.5 rounded ${
|
|
||||||
percentage === String(pct)
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pct}%
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -237,10 +295,19 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
|||||||
|
|
||||||
export function ResizeSettings() {
|
export function ResizeSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
const standardResize = useToolProcessor("resize");
|
||||||
useToolProcessor("resize");
|
const contentAwareResize = useToolProcessor("content-aware-resize");
|
||||||
|
|
||||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||||
|
const [isContentAware, setIsContentAware] = useState(false);
|
||||||
|
|
||||||
|
const handleSettingsChange = useCallback((newSettings: Record<string, unknown>) => {
|
||||||
|
setSettings(newSettings);
|
||||||
|
setIsContentAware(!!newSettings.contentAware);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const active = isContentAware ? contentAwareResize : standardResize;
|
||||||
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = active;
|
||||||
|
|
||||||
const handleProcess = () => {
|
const handleProcess = () => {
|
||||||
if (files.length > 1) {
|
if (files.length > 1) {
|
||||||
@@ -255,9 +322,11 @@ export function ResizeSettings() {
|
|||||||
const canProcess =
|
const canProcess =
|
||||||
hasFile &&
|
hasFile &&
|
||||||
!processing &&
|
!processing &&
|
||||||
(tab === "scale"
|
(isContentAware
|
||||||
? Number(settings.percentage) > 0
|
? Boolean(settings.width) || Boolean(settings.height)
|
||||||
: Boolean(settings.width) || Boolean(settings.height));
|
: tab === "scale"
|
||||||
|
? Number(settings.percentage) > 0
|
||||||
|
: Boolean(settings.width) || Boolean(settings.height));
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -266,7 +335,7 @@ export function ResizeSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<ResizeControls onChange={setSettings} />
|
<ResizeControls onChange={handleSettingsChange} />
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|||||||
+4
-2
@@ -106,7 +106,8 @@ RUN if [ "$VARIANT" = "full" ]; then \
|
|||||||
|| echo "WARNING: realesrgan not installed") && \
|
|| echo "WARNING: realesrgan not installed") && \
|
||||||
(/opt/venv/bin/pip install paddlepaddle-gpu paddleocr || echo "WARNING: PaddleOCR not installed") && \
|
(/opt/venv/bin/pip install paddlepaddle-gpu paddleocr || echo "WARNING: PaddleOCR not installed") && \
|
||||||
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
|
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
|
||||||
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \
|
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") && \
|
||||||
|
(/opt/venv/bin/pip install seam-carving || echo "WARNING: seam-carving not installed") \
|
||||||
; else \
|
; else \
|
||||||
/opt/venv/bin/pip install \
|
/opt/venv/bin/pip install \
|
||||||
Pillow numpy opencv-python-headless onnxruntime && \
|
Pillow numpy opencv-python-headless onnxruntime && \
|
||||||
@@ -114,7 +115,8 @@ RUN if [ "$VARIANT" = "full" ]; then \
|
|||||||
(/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed") && \
|
(/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed") && \
|
||||||
(/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed") && \
|
(/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed") && \
|
||||||
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
|
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
|
||||||
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \
|
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") && \
|
||||||
|
(/opt/venv/bin/pip install seam-carving || echo "WARNING: seam-carving not installed") \
|
||||||
; fi \
|
; fi \
|
||||||
; fi && rm -f /tmp/requirements.txt /tmp/requirements-gpu.txt
|
; fi && rm -f /tmp/requirements.txt /tmp/requirements-gpu.txt
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ onnxruntime==1.20.1
|
|||||||
numpy==1.26.4
|
numpy==1.26.4
|
||||||
Pillow==11.1.0
|
Pillow==11.1.0
|
||||||
opencv-python-headless==4.10.0.84
|
opencv-python-headless==4.10.0.84
|
||||||
|
seam-carving==1.1.0
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Content-aware image resize using seam carving.
|
||||||
|
Uses the seam-carving library (li-plus) with optional face protection via MediaPipe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sys.argv[1]: input image path
|
||||||
|
sys.argv[2]: output image path
|
||||||
|
sys.argv[3]: JSON settings string with keys:
|
||||||
|
- width (int, optional): target width
|
||||||
|
- height (int, optional): target height
|
||||||
|
- protectFaces (bool, optional): enable face detection for protection mask
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def emit_progress(percent, stage):
|
||||||
|
"""Emit structured progress to stderr for bridge.ts to capture."""
|
||||||
|
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_face_mask(img_array):
|
||||||
|
"""Detect faces with MediaPipe and return a boolean keep_mask."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
import mediapipe as mp
|
||||||
|
except ImportError:
|
||||||
|
emit_progress(20, "MediaPipe not available, skipping face protection")
|
||||||
|
return None
|
||||||
|
|
||||||
|
h, w = img_array.shape[:2]
|
||||||
|
mask = np.zeros((h, w), dtype=bool)
|
||||||
|
|
||||||
|
face_detection = mp.solutions.face_detection
|
||||||
|
detector = face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.5)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = detector.process(img_array)
|
||||||
|
if not results.detections:
|
||||||
|
emit_progress(20, "No faces detected")
|
||||||
|
return None
|
||||||
|
|
||||||
|
for detection in results.detections:
|
||||||
|
bbox = detection.location_data.relative_bounding_box
|
||||||
|
x = int(bbox.xmin * w)
|
||||||
|
y = int(bbox.ymin * h)
|
||||||
|
bw = int(bbox.width * w)
|
||||||
|
bh = int(bbox.height * h)
|
||||||
|
|
||||||
|
# Add 20% padding around face
|
||||||
|
pad_x = int(bw * 0.2)
|
||||||
|
pad_y = int(bh * 0.2)
|
||||||
|
x1 = max(0, x - pad_x)
|
||||||
|
y1 = max(0, y - pad_y)
|
||||||
|
x2 = min(w, x + bw + pad_x)
|
||||||
|
y2 = min(h, y + bh + pad_y)
|
||||||
|
|
||||||
|
mask[y1:y2, x1:x2] = True
|
||||||
|
|
||||||
|
emit_progress(20, f"Detected {len(results.detections)} face(s)")
|
||||||
|
return mask
|
||||||
|
finally:
|
||||||
|
detector.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print(json.dumps({"success": False, "error": "Usage: seam_carve.py <input> <output> <settings>"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
input_path = sys.argv[1]
|
||||||
|
output_path = sys.argv[2]
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = json.loads(sys.argv[3])
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
print(json.dumps({"success": False, "error": "Invalid settings JSON"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_width = settings.get("width")
|
||||||
|
target_height = settings.get("height")
|
||||||
|
protect_faces = settings.get("protectFaces", False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
print(json.dumps({"success": False, "error": "Pillow/numpy not installed"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import seam_carving
|
||||||
|
except ImportError:
|
||||||
|
print(json.dumps({"success": False, "error": "seam-carving package not installed"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
emit_progress(0, "Loading image")
|
||||||
|
img = Image.open(input_path).convert("RGB")
|
||||||
|
img_array = np.array(img)
|
||||||
|
src_h, src_w = img_array.shape[:2]
|
||||||
|
|
||||||
|
# Default to source dimensions if not specified
|
||||||
|
if target_width is None:
|
||||||
|
target_width = src_w
|
||||||
|
if target_height is None:
|
||||||
|
target_height = src_h
|
||||||
|
|
||||||
|
# Validate: shrink only
|
||||||
|
if target_width > src_w or target_height > src_h:
|
||||||
|
print(json.dumps({
|
||||||
|
"success": False,
|
||||||
|
"error": f"Content-aware resize only supports shrinking. Source is {src_w}x{src_h}, target is {target_width}x{target_height}."
|
||||||
|
}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Nothing to do
|
||||||
|
if target_width == src_w and target_height == src_h:
|
||||||
|
img.save(output_path)
|
||||||
|
print(json.dumps({"success": True, "width": src_w, "height": src_h}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Warn about large images
|
||||||
|
if src_w > 3000 or src_h > 3000:
|
||||||
|
emit_progress(5, "Large image detected, this may take a while")
|
||||||
|
|
||||||
|
# Face protection mask
|
||||||
|
keep_mask = None
|
||||||
|
if protect_faces:
|
||||||
|
emit_progress(10, "Detecting faces")
|
||||||
|
keep_mask = build_face_mask(img_array)
|
||||||
|
|
||||||
|
emit_progress(25, "Starting seam carving")
|
||||||
|
|
||||||
|
# seam_carving.resize takes size as (width, height)
|
||||||
|
result = seam_carving.resize(
|
||||||
|
img_array,
|
||||||
|
(target_width, target_height),
|
||||||
|
energy_mode="backward",
|
||||||
|
order="width-first",
|
||||||
|
keep_mask=keep_mask,
|
||||||
|
)
|
||||||
|
|
||||||
|
emit_progress(90, "Saving result")
|
||||||
|
Image.fromarray(result).save(output_path)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"width": result.shape[1],
|
||||||
|
"height": result.shape[0],
|
||||||
|
}))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"success": False, "error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,4 +3,5 @@ export { isGpuAvailable, shutdownDispatcher } from "./bridge.js";
|
|||||||
export { blurFaces } from "./face-detection.js";
|
export { blurFaces } from "./face-detection.js";
|
||||||
export { inpaint } from "./inpainting.js";
|
export { inpaint } from "./inpainting.js";
|
||||||
export { extractText } from "./ocr.js";
|
export { extractText } from "./ocr.js";
|
||||||
|
export { seamCarve } from "./seam-carving.js";
|
||||||
export { upscale } from "./upscaling.js";
|
export { upscale } from "./upscaling.js";
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { type ProgressCallback, runPythonWithProgress } from "./bridge.js";
|
||||||
|
|
||||||
|
export interface SeamCarveOptions {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
protectFaces?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeamCarveResult {
|
||||||
|
buffer: Buffer;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seamCarve(
|
||||||
|
inputBuffer: Buffer,
|
||||||
|
outputDir: string,
|
||||||
|
options: SeamCarveOptions = {},
|
||||||
|
onProgress?: ProgressCallback,
|
||||||
|
): Promise<SeamCarveResult> {
|
||||||
|
const inputPath = join(outputDir, "input_seam_carve.png");
|
||||||
|
const outputPath = join(outputDir, "output_seam_carve.png");
|
||||||
|
|
||||||
|
await writeFile(inputPath, inputBuffer);
|
||||||
|
const { stdout } = await runPythonWithProgress(
|
||||||
|
"seam_carve.py",
|
||||||
|
[inputPath, outputPath, JSON.stringify(options)],
|
||||||
|
{ onProgress },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = JSON.parse(stdout);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || "Content-aware resize failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await readFile(outputPath);
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -383,4 +383,5 @@ export const PYTHON_SIDECAR_TOOLS = [
|
|||||||
"blur-faces",
|
"blur-faces",
|
||||||
"erase-object",
|
"erase-object",
|
||||||
"ocr",
|
"ocr",
|
||||||
|
"content-aware-resize",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export const en = {
|
|||||||
description: "Auto-detect and blur faces and sensitive info",
|
description: "Auto-detect and blur faces and sensitive info",
|
||||||
},
|
},
|
||||||
"smart-crop": { name: "Smart Crop", description: "AI detects subject and crops optimally" },
|
"smart-crop": { name: "Smart Crop", description: "AI detects subject and crops optimally" },
|
||||||
|
"content-aware-resize": {
|
||||||
|
name: "Content-Aware Resize",
|
||||||
|
description: "Intelligently resize images while preserving important content",
|
||||||
|
},
|
||||||
"watermark-text": { name: "Text Watermark", description: "Add text watermark overlay" },
|
"watermark-text": { name: "Text Watermark", description: "Add text watermark overlay" },
|
||||||
"watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" },
|
"watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" },
|
||||||
"text-overlay": { name: "Text Overlay", description: "Add styled text to images" },
|
"text-overlay": { name: "Text Overlay", description: "Add styled text to images" },
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for the content-aware resize (seam carving) API endpoint.
|
||||||
|
*
|
||||||
|
* This tool uses the Python sidecar, so in CI/test environments where Python
|
||||||
|
* is not available the route will return 501 (lite mode) or 422 (Python error).
|
||||||
|
* Tests gracefully handle both scenarios while still verifying route existence
|
||||||
|
* and input validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||||
|
|
||||||
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||||
|
const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||||
|
|
||||||
|
let testApp: TestApp;
|
||||||
|
let app: TestApp["app"];
|
||||||
|
let adminToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testApp = await buildTestApp();
|
||||||
|
app = testApp.app;
|
||||||
|
adminToken = await loginAsAdmin(app);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.cleanup();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
describe("Content-Aware Resize", () => {
|
||||||
|
it("route exists and responds to POST", async () => {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
||||||
|
{
|
||||||
|
name: "settings",
|
||||||
|
content: JSON.stringify({ width: 150, height: 120, protectFaces: false }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/content-aware-resize",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": contentType,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 200 = Python available, 422 = Python error, 501 = lite mode stub
|
||||||
|
// Any of these proves the route is registered and reachable
|
||||||
|
expect([200, 422, 501]).toContain(res.statusCode);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("rejects requests without a file", async () => {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "settings", content: JSON.stringify({ width: 150 }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/content-aware-resize",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": contentType,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes with only width specified", async () => {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
||||||
|
{ name: "settings", content: JSON.stringify({ width: 150, protectFaces: false }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/content-aware-resize",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": contentType,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept 200 (Python available) or 422/501 (Python not available)
|
||||||
|
expect([200, 422, 501]).toContain(res.statusCode);
|
||||||
|
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
const resBody = JSON.parse(res.body);
|
||||||
|
expect(resBody.downloadUrl).toBeDefined();
|
||||||
|
expect(resBody.width).toBe(150);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("processes with only height specified", async () => {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
||||||
|
{ name: "settings", content: JSON.stringify({ height: 120, protectFaces: false }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/content-aware-resize",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": contentType,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([200, 422, 501]).toContain(res.statusCode);
|
||||||
|
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
const resBody = JSON.parse(res.body);
|
||||||
|
expect(resBody.downloadUrl).toBeDefined();
|
||||||
|
expect(resBody.height).toBe(120);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("rejects enlargement beyond source dimensions", async () => {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
||||||
|
{ name: "settings", content: JSON.stringify({ width: 400, protectFaces: false }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/content-aware-resize",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": contentType,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 422 = Python caught the enlargement error, 501 = lite mode
|
||||||
|
// Should never be 200 since 400 > 200px source width
|
||||||
|
expect(res.statusCode).not.toBe(200);
|
||||||
|
expect([422, 501]).toContain(res.statusCode);
|
||||||
|
|
||||||
|
if (res.statusCode === 422) {
|
||||||
|
const resBody = JSON.parse(res.body);
|
||||||
|
expect(resBody.error || resBody.details).toBeDefined();
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -35,12 +35,20 @@ describe("Lite variant", () => {
|
|||||||
"blur-faces",
|
"blur-faces",
|
||||||
"erase-object",
|
"erase-object",
|
||||||
"ocr",
|
"ocr",
|
||||||
|
"content-aware-resize",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AI tool routes return 501", () => {
|
describe("AI tool routes return 501", () => {
|
||||||
const aiTools = ["remove-background", "upscale", "blur-faces", "erase-object", "ocr"];
|
const aiTools = [
|
||||||
|
"remove-background",
|
||||||
|
"upscale",
|
||||||
|
"blur-faces",
|
||||||
|
"erase-object",
|
||||||
|
"ocr",
|
||||||
|
"content-aware-resize",
|
||||||
|
];
|
||||||
|
|
||||||
for (const toolId of aiTools) {
|
for (const toolId of aiTools) {
|
||||||
it(`POST /api/v1/tools/${toolId} returns 501`, async () => {
|
it(`POST /api/v1/tools/${toolId} returns 501`, async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user