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:
stirling-image
2026-04-08 00:10:50 +08:00
committed by GitHub
12 changed files with 768 additions and 167 deletions
@@ -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" };
},
});
}
+2
View File
@@ -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;
+233 -164
View File
@@ -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&apos;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&apos;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&apos;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&apos;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
View File
@@ -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
+1
View File
@@ -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
+161
View File
@@ -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()
+1
View File
@@ -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";
+44
View File
@@ -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,
};
}
+1
View File
@@ -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;
+4
View File
@@ -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);
});
+9 -1
View File
@@ -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 () => {