From 1460ab7a4ad8235714ebd72ab1f8092baa456032 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 7 Apr 2026 23:21:14 +0800 Subject: [PATCH 1/7] feat: add seam carving Python script with face protection --- packages/ai/python/requirements.txt | 1 + packages/ai/python/seam_carve.py | 148 ++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 packages/ai/python/seam_carve.py diff --git a/packages/ai/python/requirements.txt b/packages/ai/python/requirements.txt index 4e6235e2..2c3551bd 100644 --- a/packages/ai/python/requirements.txt +++ b/packages/ai/python/requirements.txt @@ -8,3 +8,4 @@ onnxruntime==1.20.1 numpy==1.26.4 Pillow==11.1.0 opencv-python-headless==4.10.0.84 +seam-carving==1.1.0 diff --git a/packages/ai/python/seam_carve.py b/packages/ai/python/seam_carve.py new file mode 100644 index 00000000..bbd9416f --- /dev/null +++ b/packages/ai/python/seam_carve.py @@ -0,0 +1,148 @@ +""" +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 +import numpy as np +from PIL import Image + + +def emit_progress(percent, stage): + print(json.dumps({"progress": int(percent), "stage": stage}), file=sys.stderr, flush=True) + + +def build_face_mask(img_array): + """Detect faces with MediaPipe and return a boolean keep_mask.""" + 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 "})) + sys.exit(1) + + input_path = sys.argv[1] + output_path = sys.argv[2] + settings = json.loads(sys.argv[3]) + + target_width = settings.get("width") + target_height = settings.get("height") + protect_faces = settings.get("protectFaces", False) + + 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() From 18119166f0803e1ed58160cda00c6484417f55e8 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 7 Apr 2026 23:25:40 +0800 Subject: [PATCH 2/7] refactor: align seam_carve.py with sidecar script conventions --- packages/ai/python/seam_carve.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/ai/python/seam_carve.py b/packages/ai/python/seam_carve.py index bbd9416f..51a8dc2a 100644 --- a/packages/ai/python/seam_carve.py +++ b/packages/ai/python/seam_carve.py @@ -13,16 +13,17 @@ Args: import json import sys -import numpy as np -from PIL import Image def emit_progress(percent, stage): - print(json.dumps({"progress": int(percent), "stage": stage}), file=sys.stderr, flush=True) + """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: @@ -71,12 +72,24 @@ def main(): input_path = sys.argv[1] output_path = sys.argv[2] - settings = json.loads(sys.argv[3]) + + 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: From d3b646207d3312aea9e12cc9435649b039bc4ac8 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 7 Apr 2026 23:26:47 +0800 Subject: [PATCH 3/7] feat: add seam carving AI bridge module --- packages/ai/src/index.ts | 1 + packages/ai/src/seam-carving.ts | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 packages/ai/src/seam-carving.ts diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index cbd072d8..64dc8cd0 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -3,4 +3,5 @@ export { isGpuAvailable, shutdownDispatcher } from "./bridge.js"; export { blurFaces } from "./face-detection.js"; export { inpaint } from "./inpainting.js"; export { extractText } from "./ocr.js"; +export { seamCarve } from "./seam-carving.js"; export { upscale } from "./upscaling.js"; diff --git a/packages/ai/src/seam-carving.ts b/packages/ai/src/seam-carving.ts new file mode 100644 index 00000000..5dd72e9a --- /dev/null +++ b/packages/ai/src/seam-carving.ts @@ -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 { + 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, + }; +} From d464942cd9a7c1fecc837a14211e3fc5030d5cee Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 7 Apr 2026 23:29:21 +0800 Subject: [PATCH 4/7] feat: add content-aware resize API route and registration --- .../src/routes/tools/content-aware-resize.ts | 156 ++++++++++++++++++ apps/api/src/routes/tools/index.ts | 2 + packages/shared/src/constants.ts | 1 + packages/shared/src/i18n/en.ts | 4 + 4 files changed, 163 insertions(+) create mode 100644 apps/api/src/routes/tools/content-aware-resize.ts diff --git a/apps/api/src/routes/tools/content-aware-resize.ts b/apps/api/src/routes/tools/content-aware-resize.ts new file mode 100644 index 00000000..22c8fe70 --- /dev/null +++ b/apps/api/src/routes/tools/content-aware-resize.ts @@ -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" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index c55dc68e..79419cdf 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -12,6 +12,7 @@ import { registerColorPalette } from "./color-palette.js"; import { registerCompare } from "./compare.js"; import { registerCompose } from "./compose.js"; import { registerCompress } from "./compress.js"; +import { registerContentAwareResize } from "./content-aware-resize.js"; import { registerConvert } from "./convert.js"; import { registerCrop } from "./crop.js"; import { registerEditMetadata } from "./edit-metadata.js"; @@ -126,6 +127,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "blur-faces", register: registerBlurFaces }, { id: "erase-object", register: registerEraseObject }, { id: "smart-crop", register: registerSmartCrop }, + { id: "content-aware-resize", register: registerContentAwareResize }, ]; let skipped = 0; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index bb835906..86270e06 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -383,4 +383,5 @@ export const PYTHON_SIDECAR_TOOLS = [ "blur-faces", "erase-object", "ocr", + "content-aware-resize", ] as const; diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 0dc9e190..ed3fde4d 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -66,6 +66,10 @@ export const en = { description: "Auto-detect and blur faces and sensitive info", }, "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-image": { name: "Image Watermark", description: "Overlay a logo as watermark" }, "text-overlay": { name: "Text Overlay", description: "Add styled text to images" }, From aace4caf0c5b10059a31e9dff5476d11b34b4323 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 7 Apr 2026 23:33:34 +0800 Subject: [PATCH 5/7] feat: add content-aware resize toggle to resize settings UI --- .../src/components/tools/resize-settings.tsx | 397 ++++++++++-------- 1 file changed, 233 insertions(+), 164 deletions(-) diff --git a/apps/web/src/components/tools/resize-settings.tsx b/apps/web/src/components/tools/resize-settings.tsx index 00a7bfd1..0bc3ac9e 100644 --- a/apps/web/src/components/tools/resize-settings.tsx +++ b/apps/web/src/components/tools/resize-settings.tsx @@ -1,6 +1,6 @@ import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared"; 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 { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; @@ -30,6 +30,8 @@ export function ResizeControls({ onChange }: ResizeControlsProps) { const [fit, setFit] = useState("cover"); const [lockAspect, setLockAspect] = useState(true); const [withoutEnlargement, setWithoutEnlargement] = useState(false); + const [contentAware, setContentAware] = useState(false); + const [protectFaces, setProtectFaces] = useState(true); const onChangeRef = useRef(onChange); useEffect(() => { @@ -38,7 +40,12 @@ export function ResizeControls({ onChange }: ResizeControlsProps) { useEffect(() => { const settings: Record = {}; - 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); } else { if (width) settings.width = Number(width); @@ -47,7 +54,7 @@ export function ResizeControls({ onChange }: ResizeControlsProps) { settings.withoutEnlargement = withoutEnlargement; } 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 key = `${preset.platform}-${preset.name}`; @@ -65,171 +72,222 @@ export function ResizeControls({ onChange }: ResizeControlsProps) { const tabClass = (t: ResizeTab) => `flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`; + const dimensionInputs = ( +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ ); + return (
- {/* Tab selector */} -
-
- - - -
+ {/* Content-aware toggle */} +
+ Content-aware +
- {/* Presets tab */} - {tab === "presets" && ( -
- {platforms.map((platform) => ( -
-

{platform}

-
- {SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => { - const key = `${preset.platform}-${preset.name}`; - const isSelected = selectedPreset === key; - return ( + {/* Content-aware inputs */} + {contentAware && ( +
+ {dimensionInputs} + + {/* Protect faces */} + +
+ )} + + {/* Standard resize tabs */} + {!contentAware && ( + <> + {/* Tab selector */} +
+
+ + + +
+
+ + {/* Presets tab */} + {tab === "presets" && ( +
+ {platforms.map((platform) => ( +
+

{platform}

+
+ {SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => { + const key = `${preset.platform}-${preset.name}`; + const isSelected = selectedPreset === key; + return ( + + ); + })} +
+
+ ))} + + {/* Don't enlarge */} + +
+ )} + + {/* Custom Size tab */} + {tab === "custom" && ( +
+ {dimensionInputs} + + {/* Fit mode */} +
+

Fit Mode

+
+ {(Object.keys(FIT_LABELS) as FitMode[]).map((f) => ( - ); - })} + ))} +
+
+ + {/* Don't enlarge */} + +
+ )} + + {/* Scale tab */} + {tab === "scale" && ( +
+
+ + 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" + /> +
+
+ {[25, 50, 75].map((pct) => ( + + ))}
- ))} - - {/* Don't enlarge */} - -
- )} - - {/* Custom Size tab */} - {tab === "custom" && ( -
-
-
- - 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" - /> -
- -
- - 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" - /> -
-
- - {/* Fit mode */} -
-

Fit Mode

-
- {(Object.keys(FIT_LABELS) as FitMode[]).map((f) => ( - - ))} -
-
- - {/* Don't enlarge */} - -
- )} - - {/* Scale tab */} - {tab === "scale" && ( -
-
- - 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" - /> -
-
- {[25, 50, 75].map((pct) => ( - - ))} -
-
+ )} + )}
); @@ -237,10 +295,19 @@ export function ResizeControls({ onChange }: ResizeControlsProps) { export function ResizeSettings() { const { files } = useFileStore(); - const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = - useToolProcessor("resize"); + const standardResize = useToolProcessor("resize"); + const contentAwareResize = useToolProcessor("content-aware-resize"); const [settings, setSettings] = useState>({}); + const [isContentAware, setIsContentAware] = useState(false); + + const handleSettingsChange = useCallback((newSettings: Record) => { + setSettings(newSettings); + setIsContentAware(!!newSettings.contentAware); + }, []); + + const active = isContentAware ? contentAwareResize : standardResize; + const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = active; const handleProcess = () => { if (files.length > 1) { @@ -255,9 +322,11 @@ export function ResizeSettings() { const canProcess = hasFile && !processing && - (tab === "scale" - ? Number(settings.percentage) > 0 - : Boolean(settings.width) || Boolean(settings.height)); + (isContentAware + ? Boolean(settings.width) || Boolean(settings.height) + : tab === "scale" + ? Number(settings.percentage) > 0 + : Boolean(settings.width) || Boolean(settings.height)); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -266,7 +335,7 @@ export function ResizeSettings() { return (
- + {/* Error */} {error &&

{error}

} From fc7d355d08281f088efdc041457cfde8cc903d87 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 7 Apr 2026 23:36:51 +0800 Subject: [PATCH 6/7] test: add integration tests for content-aware resize endpoint --- .../integration/content-aware-resize.test.ts | 152 ++++++++++++++++++ tests/integration/lite-variant.test.ts | 10 +- 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 tests/integration/content-aware-resize.test.ts diff --git a/tests/integration/content-aware-resize.test.ts b/tests/integration/content-aware-resize.test.ts new file mode 100644 index 00000000..a182a4ad --- /dev/null +++ b/tests/integration/content-aware-resize.test.ts @@ -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); +}); diff --git a/tests/integration/lite-variant.test.ts b/tests/integration/lite-variant.test.ts index 5933b7f0..7ba2978f 100644 --- a/tests/integration/lite-variant.test.ts +++ b/tests/integration/lite-variant.test.ts @@ -35,12 +35,20 @@ describe("Lite variant", () => { "blur-faces", "erase-object", "ocr", + "content-aware-resize", ]); }); }); 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) { it(`POST /api/v1/tools/${toolId} returns 501`, async () => { From 4435559d5fde066def688413d54728c2ca9c2603 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 7 Apr 2026 23:37:55 +0800 Subject: [PATCH 7/7] chore: add seam-carving to Docker Python dependencies --- docker/Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 84f5c91e..a432d06c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -106,7 +106,8 @@ RUN if [ "$VARIANT" = "full" ]; then \ || echo "WARNING: realesrgan 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 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 \ /opt/venv/bin/pip install \ 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 paddlepaddle paddleocr || echo "WARNING: PaddleOCR 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 && rm -f /tmp/requirements.txt /tmp/requirements-gpu.txt