chore: remove swagger deps, parallelize CI jobs

- Remove @fastify/swagger and @fastify/swagger-ui (API docs live on GitHub Pages)
- Run typecheck, build, and docker CI jobs in parallel instead of sequentially
This commit is contained in:
Siddharth Kumar Sah
2026-03-24 00:41:54 +08:00
parent fbdbe0949a
commit 0aa2a5e5de
18 changed files with 360 additions and 337 deletions
-2
View File
@@ -30,7 +30,6 @@ jobs:
build:
name: Build
runs-on: ubuntu-latest
needs: typecheck
steps:
- uses: actions/checkout@v4
@@ -47,7 +46,6 @@ jobs:
docker:
name: Docker Build Test
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
-2
View File
@@ -15,8 +15,6 @@
"@fastify/multipart": "^9.0.0",
"@fastify/rate-limit": "^10.2.0",
"@fastify/static": "^8.1.0",
"@fastify/swagger": "^9.4.0",
"@fastify/swagger-ui": "^5.2.0",
"@stirling-image/ai": "workspace:*",
"@stirling-image/image-engine": "workspace:*",
"@stirling-image/shared": "workspace:*",
-21
View File
@@ -1,10 +1,7 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
import { env } from "./config.js";
import { APP_VERSION } from "@stirling-image/shared";
import { runMigrations } from "./db/migrate.js";
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js";
import { registerUpload } from "./plugins/upload.js";
@@ -51,24 +48,6 @@ await app.register(rateLimit, {
timeWindow: "1 minute",
});
// Swagger / OpenAPI documentation (dev only)
if (process.env.NODE_ENV !== "production") {
await app.register(swagger, {
openapi: {
info: {
title: "Stirling Image API",
description: "API for Stirling Image — self-hosted image processing suite",
version: APP_VERSION,
},
servers: [{ url: `http://localhost:1349` }],
},
});
await app.register(swaggerUi, {
routePrefix: "/api/docs",
});
}
// Multipart upload support
await registerUpload(app);
+62 -41
View File
@@ -117,17 +117,34 @@ export async function registerBatchRoutes(
};
updateJobProgress({ ...progress });
// Set up response headers for ZIP streaming
// Tell Fastify we're taking over the response — without this,
// Fastify's lifecycle hooks conflict with reply.raw.writeHead()
// and can throw unhandled errors that crash the process.
reply.hijack();
// Set up response headers for ZIP streaming.
// X-File-Order must be URI-encoded because filenames can contain
// spaces/special chars that are invalid in HTTP header values.
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Order": files.map(f => f.filename).join(","),
"X-File-Order": files.map(f => encodeURIComponent(f.filename)).join(","),
});
// Create ZIP archive that pipes directly to the response
const archive = archiver("zip", { zlib: { level: 5 } });
// Handle archive-level errors to prevent unhandled exceptions
// that would crash the server process.
archive.on("error", (err) => {
request.log.error({ err }, "Archiver error during batch processing");
if (!reply.raw.writableEnded) {
reply.raw.end();
}
});
archive.pipe(reply.raw);
// Use p-queue for concurrency control
@@ -154,50 +171,54 @@ export async function registerBatchRoutes(
}
// Process all files through the queue
const tasks = files.map((file) =>
queue.add(async () => {
progress.currentFile = file.filename;
updateJobProgress({ ...progress });
// Validate the image
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: `Invalid image: ${validation.reason}`,
});
progress.completedFiles++;
try {
const tasks = files.map((file) =>
queue.add(async () => {
progress.currentFile = file.filename;
updateJobProgress({ ...progress });
return;
}
try {
const result = await toolConfig.process(
file.buffer,
settings,
file.filename,
);
// Validate the image
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: `Invalid image: ${validation.reason}`,
});
progress.completedFiles++;
updateJobProgress({ ...progress });
return;
}
const zipFilename = getUniqueName(result.filename);
archive.append(result.buffer, { name: zipFilename });
try {
const result = await toolConfig.process(
file.buffer,
settings,
file.filename,
);
progress.completedFiles++;
updateJobProgress({ ...progress });
} catch (err) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: err instanceof Error ? err.message : "Processing failed",
});
progress.completedFiles++;
updateJobProgress({ ...progress });
}
}),
);
const zipFilename = getUniqueName(result.filename);
archive.append(result.buffer, { name: zipFilename });
// Wait for all tasks to complete
await Promise.all(tasks);
progress.completedFiles++;
updateJobProgress({ ...progress });
} catch (err) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: err instanceof Error ? err.message : "Processing failed",
});
progress.completedFiles++;
updateJobProgress({ ...progress });
}
}),
);
// Wait for all tasks to complete
await Promise.all(tasks);
} catch (err) {
request.log.error({ err }, "Unexpected error in batch queue");
}
// Finalize progress
progress.status =
+3
View File
@@ -86,6 +86,9 @@ export async function registerProgressRoutes(
) => {
const { jobId } = request.params;
// Take over the response from Fastify for SSE streaming
reply.hijack();
// Send SSE headers via the raw Node response
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
+1
View File
@@ -65,6 +65,7 @@ export function registerBulkRename(app: FastifyInstance) {
try {
const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
+1
View File
@@ -43,6 +43,7 @@ export function registerFavicon(app: FastifyInstance) {
try {
const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
+1
View File
@@ -70,6 +70,7 @@ export function registerSplit(app: FastifyInstance) {
const jobId = randomUUID();
// Set up response headers for ZIP
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
@@ -79,15 +79,19 @@ export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssF
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain" as const,
...(previewTransform && { transform: previewTransform }),
...(previewTransform && {
transform: previewTransform,
transition: "transform 0.25s ease",
}),
}
: {
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
transformOrigin: "center center",
...(previewTransform && { transition: "transform 0.25s ease" }),
};
return (
<div className="flex flex-col w-full h-full max-w-3xl mx-auto">
<div className="flex flex-col w-full h-full max-w-3xl mx-auto min-h-0">
{/* Toolbar */}
<div className="flex items-center justify-center gap-1 py-2 px-3 border-b border-border shrink-0">
<button
@@ -22,14 +22,14 @@ export function MultiImageViewer() {
const hasProcessed = !!currentEntry.processedUrl;
return (
<div className="flex flex-col w-full h-full" onKeyDown={hasMultiple ? handleKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
<div className="flex-1 relative flex items-center justify-center">
<div className="flex flex-col w-full h-full min-h-0" onKeyDown={hasMultiple ? handleKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
<div className="flex-1 relative flex items-center justify-center min-h-0">
{hasMultiple && hasPrev && (
<button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image">
<ChevronLeft className="h-4 w-4" />
</button>
)}
<div className="w-full h-full">
<div className="w-full h-full min-h-0">
{hasProcessed ? (
<BeforeAfterSlider beforeSrc={currentEntry.blobUrl} afterSrc={currentEntry.processedUrl!} beforeSize={currentEntry.originalSize} afterSize={currentEntry.processedSize ?? undefined} />
) : (
@@ -27,15 +27,6 @@ export function SideBySideComparison({
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null;
const checkerboard = {
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
};
return (
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
{/* Side-by-side images */}
@@ -45,14 +36,11 @@ export function SideBySideComparison({
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Original
</span>
<div
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
<img
src={beforeSrc}
alt="Original"
className="max-w-full max-h-full object-contain"
className="max-w-full max-h-[56vh] object-contain rounded-sm"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
@@ -70,19 +58,16 @@ export function SideBySideComparison({
</div>
</div>
{/* Resized */}
{/* Processed */}
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Resized
Processed
</span>
<div
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
<img
src={afterSrc}
alt="Resized"
className="max-w-full max-h-full object-contain"
alt="Processed"
className="max-w-full max-h-[56vh] object-contain rounded-sm"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
@@ -22,7 +22,7 @@ export function ResizeSettings() {
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("resize");
const [tab, setTab] = useState<ResizeTab>("presets");
const [tab, setTab] = useState<ResizeTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [width, setWidth] = useState<string>("");
const [height, setHeight] = useState<string>("");
@@ -84,15 +84,15 @@ export function ResizeSettings() {
{/* Tab selector */}
<div>
<div className="flex gap-1">
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
</button>
<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>
@@ -1,8 +1,7 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import {
Download,
RotateCcw,
RotateCw,
FlipHorizontal,
@@ -22,24 +21,42 @@ interface RotateSettingsProps {
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("rotate");
const [angle, setAngle] = useState(0);
// Quick rotation in 90° steps: 0, 90, 180, 270
const [rotation, setRotation] = useState(0);
// Fine straighten adjustment: -45 to +45
const [straighten, setStraighten] = useState(0);
const [flipH, setFlipH] = useState(false);
const [flipV, setFlipV] = useState(false);
const totalAngle = rotation + straighten;
// Emit preview transform on every change
useEffect(() => {
onPreviewTransform?.({ rotate: angle, flipH, flipV });
}, [angle, flipH, flipV, onPreviewTransform]);
onPreviewTransform?.({ rotate: totalAngle, flipH, flipV });
}, [totalAngle, flipH, flipV, onPreviewTransform]);
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
const rotateRight = () => setAngle((a) => (a + 90) % 360);
// Reset controls after successful processing
const prevProcessing = useRef(processing);
useEffect(() => {
if (prevProcessing.current && !processing && !error) {
setRotation(0);
setStraighten(0);
setFlipH(false);
setFlipV(false);
}
prevProcessing.current = processing;
}, [processing, error]);
const rotateLeft = () => setRotation((r) => r - 90);
const rotateRight = () => setRotation((r) => r + 90);
const handleProcess = () => {
const backendAngle = ((totalAngle % 360) + 360) % 360;
const settings = {
angle,
angle: backendAngle,
horizontal: flipH,
vertical: flipV,
};
@@ -51,52 +68,78 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
};
const hasFile = files.length > 0;
const hasChanges = angle !== 0 || flipH || flipV;
const hasChanges = totalAngle !== 0 || flipH || flipV;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && hasChanges && !processing) handleProcess();
};
const handleReset = () => {
setRotation(0);
setStraighten(0);
setFlipH(false);
setFlipV(false);
};
// Display angle normalized to 0-359
const displayAngle = ((totalAngle % 360) + 360) % 360;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Quick rotate buttons */}
{/* Quick rotate */}
<div>
<label className="text-xs text-muted-foreground">Quick Rotate</label>
<div className="flex gap-2 mt-1">
<label className="text-xs text-muted-foreground">Rotate</label>
<div className="flex items-center gap-2 mt-1">
<button
type="button"
onClick={rotateLeft}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
title="Rotate 90° counter-clockwise"
>
<RotateCcw className="h-4 w-4" />
90 Left
Left
</button>
<div className="px-3 py-1.5 rounded-md bg-background border border-border text-center min-w-[4rem]">
<span className="text-sm font-mono font-medium tabular-nums">
{displayAngle}°
</span>
</div>
<button
type="button"
onClick={rotateRight}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
title="Rotate 90° clockwise"
>
Right
<RotateCw className="h-4 w-4" />
90 Right
</button>
</div>
</div>
{/* Angle slider */}
{/* Straighten */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Angle</label>
<span className="text-xs font-mono text-foreground">{angle} deg</span>
<label className="text-xs text-muted-foreground">Straighten</label>
<span className="text-xs font-mono tabular-nums text-muted-foreground">
{straighten > 0 ? "+" : ""}
{straighten}°
</span>
</div>
<input
type="range"
min={0}
max={360}
value={angle}
onChange={(e) => setAngle(Number(e.target.value))}
min={-45}
max={45}
step={0.5}
value={straighten}
onChange={(e) => setStraighten(Number(e.target.value))}
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>-45°</span>
<span>0°</span>
<span>+45°</span>
</div>
</div>
{/* Flip buttons */}
@@ -106,7 +149,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<button
type="button"
onClick={() => setFlipH(!flipH)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${
flipH
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
@@ -118,7 +161,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<button
type="button"
onClick={() => setFlipV(!flipV)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${
flipV
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
@@ -130,6 +173,17 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
</div>
</div>
{/* Reset all */}
{hasChanges && (
<button
type="button"
onClick={handleReset}
className="w-full text-xs text-muted-foreground hover:text-foreground py-1"
>
Reset all changes
</button>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
@@ -152,18 +206,6 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
{files.length > 1 ? `Apply (${files.length} files)` : "Apply"}
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
+1 -1
View File
@@ -289,7 +289,7 @@ export function useToolProcessor(toolId: string) {
const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer() as ArrayBuffer);
const extracted = unzipSync(zipBuffer);
const fileOrder = response.headers.get("X-File-Order")?.split(",") ?? [];
const fileOrder = (response.headers.get("X-File-Order")?.split(",") ?? []).map(decodeURIComponent);
const entries = useFileStore.getState().entries;
const extractedNames = Object.keys(extracted);
+5 -2
View File
@@ -2,6 +2,7 @@ import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { AppLayout } from "@/components/layout/app-layout";
import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { useFileStore } from "@/stores/file-store";
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
import * as icons from "lucide-react";
@@ -124,8 +125,10 @@ export function HomePage() {
</div>
{/* Right panel: Image preview */}
<div className="flex-1 flex items-center justify-center p-6">
{originalBlobUrl ? (
<div className="flex-1 flex items-center justify-center p-6 min-h-0">
{files.length > 1 ? (
<MultiImageViewer />
) : originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
+178 -117
View File
@@ -4,8 +4,8 @@ import type { Crop } from "react-image-crop";
import { TOOLS } from "@stirling-image/shared";
import { AppLayout } from "@/components/layout/app-layout";
import { Dropzone } from "@/components/common/dropzone";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { ImageViewer } from "@/components/common/image-viewer";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
import { ReviewPanel } from "@/components/common/review-panel";
@@ -55,7 +55,7 @@ import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
import * as icons from "lucide-react";
import { CheckCircle2, Download } from "lucide-react";
import { CheckCircle2, Download, ChevronLeft, ChevronRight } from "lucide-react";
const COLOR_TOOL_IDS = new Set([
"brightness-contrast",
@@ -66,8 +66,9 @@ const COLOR_TOOL_IDS = new Set([
// Tools that don't need a file dropzone (they generate content or have custom UI)
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop"]);
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate"]);
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]);
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
function ToolSettingsPanel({
@@ -194,8 +195,20 @@ export function ToolPage() {
undoProcessing,
batchZipBlob,
batchZipFilename,
selectedIndex,
setSelectedIndex,
navigateNext,
navigatePrev,
} = useFileStore();
const isMobile = useMobile();
const hasMultiple = entries.length > 1;
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const handleImageKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") { e.preventDefault(); navigatePrev(); }
else if (e.key === "ArrowRight") { e.preventDefault(); navigateNext(); }
}, [navigateNext, navigatePrev]);
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
@@ -367,63 +380,87 @@ export function ToolPage() {
)}
{/* Main area: Dropzone / Image Viewer / Before-After */}
<div className="flex-1 flex items-center justify-center p-4">
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : files.length > 1 ? (
<MultiImageViewer />
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
<div className="flex-1 flex flex-col min-h-0" onKeyDown={hasMultiple ? handleImageKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0">
{hasMultiple && hasPrev && (
<button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image">
<ChevronLeft className="h-4 w-4" />
</button>
)}
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)}
{hasMultiple && hasNext && (
<button onClick={navigateNext} className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Next image">
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
</div>
{hasMultiple && (
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
)}
</div>
</div>
@@ -510,63 +547,87 @@ export function ToolPage() {
</div>
{/* Main area: Dropzone / Image Viewer / Before-After */}
<div className="flex-1 flex items-center justify-center p-6">
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : files.length > 1 ? (
<MultiImageViewer />
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
<div className="flex-1 flex flex-col min-h-0" onKeyDown={hasMultiple ? handleImageKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
{hasMultiple && hasPrev && (
<button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image">
<ChevronLeft className="h-4 w-4" />
</button>
)}
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)}
{hasMultiple && hasNext && (
<button onClick={navigateNext} className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Next image">
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
</div>
{hasMultiple && (
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
)}
</div>
</div>
+6 -6
View File
@@ -6,7 +6,7 @@
# ============================================
# Stage 1: Build the frontend (Vite + React)
# ============================================
FROM node:22-bookworm AS builder
FROM --platform=linux/amd64 node:22-bookworm AS builder
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
@@ -35,7 +35,7 @@ RUN pnpm --filter @stirling-image/web build
# ============================================
# Stage 2: Production runtime
# ============================================
FROM node:22-bookworm AS production
FROM --platform=linux/amd64 node:22-bookworm AS production
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
@@ -67,10 +67,6 @@ RUN /opt/venv/bin/pip install --no-cache-dir --upgrade pip && \
(/opt/venv/bin/pip install --no-cache-dir lama-cleaner || echo "WARNING: lama-cleaner not installed - object eraser will be unavailable") && \
rm /tmp/requirements.txt
# Remove build tools no longer needed in production
RUN apt-get purge -y --auto-remove build-essential python3-dev && \
rm -rf /var/lib/apt/lists/*
# Pre-download ALL AI model weights into the image (no first-use download delays)
# This makes the Docker image fully self-contained — works offline
COPY docker/download_models.py /tmp/download_models.py
@@ -99,6 +95,10 @@ COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/
# Install production dependencies (tsx is now in prod deps)
RUN pnpm install --frozen-lockfile --prod
# Remove build tools no longer needed in production
RUN apt-get purge -y --auto-remove build-essential python3-dev && \
rm -rf /var/lib/apt/lists/*
# Copy source code for API (tsx runs TS directly - no build step needed)
COPY apps/api/src ./apps/api/src
COPY apps/api/drizzle ./apps/api/drizzle
+2 -76
View File
@@ -74,12 +74,6 @@ importers:
'@fastify/static':
specifier: ^8.1.0
version: 8.3.0
'@fastify/swagger':
specifier: ^9.4.0
version: 9.7.0
'@fastify/swagger-ui':
specifier: ^5.2.0
version: 5.2.5
'@stirling-image/ai':
specifier: workspace:*
version: link:../../packages/ai
@@ -1310,15 +1304,6 @@ packages:
'@fastify/static@8.3.0':
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
'@fastify/static@9.0.0':
resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==}
'@fastify/swagger-ui@5.2.5':
resolution: {integrity: sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==}
'@fastify/swagger@9.7.0':
resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==}
'@iconify-json/simple-icons@1.2.74':
resolution: {integrity: sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA==}
@@ -2829,10 +2814,6 @@ packages:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
content-disposition@1.0.1:
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
engines: {node: '>=18'}
conventional-changelog-angular@8.3.0:
resolution: {integrity: sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==}
engines: {node: '>=18'}
@@ -3411,10 +3392,6 @@ packages:
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -3679,10 +3656,6 @@ packages:
json-schema-ref-resolver@3.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
json-schema-resolver@3.0.0:
resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==}
engines: {node: '>=20'}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@@ -4122,9 +4095,6 @@ packages:
oniguruma-to-es@3.1.1:
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
p-each-series@3.0.0:
resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==}
engines: {node: '>=12'}
@@ -6110,33 +6080,6 @@ snapshots:
fastq: 1.20.1
glob: 11.1.0
'@fastify/static@9.0.0':
dependencies:
'@fastify/accept-negotiator': 2.0.1
'@fastify/send': 4.1.0
content-disposition: 1.0.1
fastify-plugin: 5.1.0
fastq: 1.20.1
glob: 13.0.6
'@fastify/swagger-ui@5.2.5':
dependencies:
'@fastify/static': 9.0.0
fastify-plugin: 5.1.0
openapi-types: 12.1.3
rfdc: 1.4.1
yaml: 2.8.3
'@fastify/swagger@9.7.0':
dependencies:
fastify-plugin: 5.1.0
json-schema-resolver: 3.0.0
openapi-types: 12.1.3
rfdc: 1.4.1
yaml: 2.8.3
transitivePeerDependencies:
- supports-color
'@iconify-json/simple-icons@1.2.74':
dependencies:
'@iconify/types': 2.0.0
@@ -7845,8 +7788,6 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
content-disposition@1.0.1: {}
conventional-changelog-angular@8.3.0:
dependencies:
compare-func: 2.0.0
@@ -8453,12 +8394,6 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 2.0.2
glob@13.0.6:
dependencies:
minimatch: 10.2.4
minipass: 7.1.3
path-scurry: 2.0.2
global@4.4.0:
dependencies:
min-document: 2.19.2
@@ -8733,14 +8668,6 @@ snapshots:
dependencies:
dequal: 2.0.3
json-schema-resolver@3.0.0:
dependencies:
debug: 4.4.3
fast-uri: 3.1.0
rfdc: 1.4.1
transitivePeerDependencies:
- supports-color
json-schema-traverse@1.0.0: {}
json-with-bigint@3.5.8: {}
@@ -9083,8 +9010,6 @@ snapshots:
regex: 6.1.0
regex-recursion: 6.0.2
openapi-types@12.1.3: {}
p-each-series@3.0.0: {}
p-event@6.0.1:
@@ -10301,7 +10226,8 @@ snapshots:
yallist@3.1.1: {}
yaml@2.8.3: {}
yaml@2.8.3:
optional: true
yargs-parser@18.1.3:
dependencies: