mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -30,7 +30,6 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: typecheck
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ jobs:
|
|||||||
docker:
|
docker:
|
||||||
name: Docker Build Test
|
name: Docker Build Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
"@fastify/multipart": "^9.0.0",
|
"@fastify/multipart": "^9.0.0",
|
||||||
"@fastify/rate-limit": "^10.2.0",
|
"@fastify/rate-limit": "^10.2.0",
|
||||||
"@fastify/static": "^8.1.0",
|
"@fastify/static": "^8.1.0",
|
||||||
"@fastify/swagger": "^9.4.0",
|
|
||||||
"@fastify/swagger-ui": "^5.2.0",
|
|
||||||
"@stirling-image/ai": "workspace:*",
|
"@stirling-image/ai": "workspace:*",
|
||||||
"@stirling-image/image-engine": "workspace:*",
|
"@stirling-image/image-engine": "workspace:*",
|
||||||
"@stirling-image/shared": "workspace:*",
|
"@stirling-image/shared": "workspace:*",
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
import swagger from "@fastify/swagger";
|
|
||||||
import swaggerUi from "@fastify/swagger-ui";
|
|
||||||
import { env } from "./config.js";
|
import { env } from "./config.js";
|
||||||
import { APP_VERSION } from "@stirling-image/shared";
|
|
||||||
import { runMigrations } from "./db/migrate.js";
|
import { runMigrations } from "./db/migrate.js";
|
||||||
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js";
|
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js";
|
||||||
import { registerUpload } from "./plugins/upload.js";
|
import { registerUpload } from "./plugins/upload.js";
|
||||||
@@ -51,24 +48,6 @@ await app.register(rateLimit, {
|
|||||||
timeWindow: "1 minute",
|
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
|
// Multipart upload support
|
||||||
await registerUpload(app);
|
await registerUpload(app);
|
||||||
|
|
||||||
|
|||||||
@@ -117,17 +117,34 @@ export async function registerBatchRoutes(
|
|||||||
};
|
};
|
||||||
updateJobProgress({ ...progress });
|
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, {
|
reply.raw.writeHead(200, {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
"X-Job-Id": jobId,
|
"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
|
// Create ZIP archive that pipes directly to the response
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
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);
|
archive.pipe(reply.raw);
|
||||||
|
|
||||||
// Use p-queue for concurrency control
|
// Use p-queue for concurrency control
|
||||||
@@ -154,6 +171,7 @@ export async function registerBatchRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process all files through the queue
|
// Process all files through the queue
|
||||||
|
try {
|
||||||
const tasks = files.map((file) =>
|
const tasks = files.map((file) =>
|
||||||
queue.add(async () => {
|
queue.add(async () => {
|
||||||
progress.currentFile = file.filename;
|
progress.currentFile = file.filename;
|
||||||
@@ -198,6 +216,9 @@ export async function registerBatchRoutes(
|
|||||||
|
|
||||||
// Wait for all tasks to complete
|
// Wait for all tasks to complete
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, "Unexpected error in batch queue");
|
||||||
|
}
|
||||||
|
|
||||||
// Finalize progress
|
// Finalize progress
|
||||||
progress.status =
|
progress.status =
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export async function registerProgressRoutes(
|
|||||||
) => {
|
) => {
|
||||||
const { jobId } = request.params;
|
const { jobId } = request.params;
|
||||||
|
|
||||||
|
// Take over the response from Fastify for SSE streaming
|
||||||
|
reply.hijack();
|
||||||
|
|
||||||
// Send SSE headers via the raw Node response
|
// Send SSE headers via the raw Node response
|
||||||
reply.raw.writeHead(200, {
|
reply.raw.writeHead(200, {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function registerBulkRename(app: FastifyInstance) {
|
|||||||
try {
|
try {
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
|
|
||||||
|
reply.hijack();
|
||||||
reply.raw.writeHead(200, {
|
reply.raw.writeHead(200, {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function registerFavicon(app: FastifyInstance) {
|
|||||||
try {
|
try {
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
|
|
||||||
|
reply.hijack();
|
||||||
reply.raw.writeHead(200, {
|
reply.raw.writeHead(200, {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function registerSplit(app: FastifyInstance) {
|
|||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
|
|
||||||
// Set up response headers for ZIP
|
// Set up response headers for ZIP
|
||||||
|
reply.hijack();
|
||||||
reply.raw.writeHead(200, {
|
reply.raw.writeHead(200, {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.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%",
|
maxWidth: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
objectFit: "contain" as const,
|
objectFit: "contain" as const,
|
||||||
...(previewTransform && { transform: previewTransform }),
|
...(previewTransform && {
|
||||||
|
transform: previewTransform,
|
||||||
|
transition: "transform 0.25s ease",
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
|
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
|
...(previewTransform && { transition: "transform 0.25s ease" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-center gap-1 py-2 px-3 border-b border-border shrink-0">
|
<div className="flex items-center justify-center gap-1 py-2 px-3 border-b border-border shrink-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ export function MultiImageViewer() {
|
|||||||
const hasProcessed = !!currentEntry.processedUrl;
|
const hasProcessed = !!currentEntry.processedUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full" onKeyDown={hasMultiple ? handleKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
|
<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">
|
<div className="flex-1 relative flex items-center justify-center min-h-0">
|
||||||
{hasMultiple && hasPrev && (
|
{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">
|
<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" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full min-h-0">
|
||||||
{hasProcessed ? (
|
{hasProcessed ? (
|
||||||
<BeforeAfterSlider beforeSrc={currentEntry.blobUrl} afterSrc={currentEntry.processedUrl!} beforeSize={currentEntry.originalSize} afterSize={currentEntry.processedSize ?? undefined} />
|
<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)
|
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
||||||
{/* Side-by-side images */}
|
{/* Side-by-side images */}
|
||||||
@@ -45,14 +36,11 @@ export function SideBySideComparison({
|
|||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Original
|
Original
|
||||||
</span>
|
</span>
|
||||||
<div
|
<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]">
|
||||||
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
|
||||||
style={checkerboard}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={beforeSrc}
|
src={beforeSrc}
|
||||||
alt="Original"
|
alt="Original"
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-[56vh] object-contain rounded-sm"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
@@ -70,19 +58,16 @@ export function SideBySideComparison({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resized */}
|
{/* Processed */}
|
||||||
<div className="flex-1 flex flex-col items-center gap-2">
|
<div className="flex-1 flex flex-col items-center gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Resized
|
Processed
|
||||||
</span>
|
</span>
|
||||||
<div
|
<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]">
|
||||||
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
|
||||||
style={checkerboard}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={afterSrc}
|
src={afterSrc}
|
||||||
alt="Resized"
|
alt="Processed"
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-[56vh] object-contain rounded-sm"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function ResizeSettings() {
|
|||||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||||
useToolProcessor("resize");
|
useToolProcessor("resize");
|
||||||
|
|
||||||
const [tab, setTab] = useState<ResizeTab>("presets");
|
const [tab, setTab] = useState<ResizeTab>("custom");
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||||
const [width, setWidth] = useState<string>("");
|
const [width, setWidth] = useState<string>("");
|
||||||
const [height, setHeight] = useState<string>("");
|
const [height, setHeight] = useState<string>("");
|
||||||
@@ -84,15 +84,15 @@ export function ResizeSettings() {
|
|||||||
{/* Tab selector */}
|
{/* Tab selector */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-1">
|
<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")}>
|
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
||||||
Custom Size
|
Custom Size
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||||||
Scale
|
Scale
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
||||||
|
Presets
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import {
|
import {
|
||||||
Download,
|
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
FlipHorizontal,
|
FlipHorizontal,
|
||||||
@@ -22,24 +21,42 @@ interface RotateSettingsProps {
|
|||||||
|
|
||||||
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
const { processFiles, processAllFiles, processing, error, progress } =
|
||||||
useToolProcessor("rotate");
|
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 [flipH, setFlipH] = useState(false);
|
||||||
const [flipV, setFlipV] = useState(false);
|
const [flipV, setFlipV] = useState(false);
|
||||||
|
|
||||||
|
const totalAngle = rotation + straighten;
|
||||||
|
|
||||||
// Emit preview transform on every change
|
// Emit preview transform on every change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onPreviewTransform?.({ rotate: angle, flipH, flipV });
|
onPreviewTransform?.({ rotate: totalAngle, flipH, flipV });
|
||||||
}, [angle, flipH, flipV, onPreviewTransform]);
|
}, [totalAngle, flipH, flipV, onPreviewTransform]);
|
||||||
|
|
||||||
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
|
// Reset controls after successful processing
|
||||||
const rotateRight = () => setAngle((a) => (a + 90) % 360);
|
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 handleProcess = () => {
|
||||||
|
const backendAngle = ((totalAngle % 360) + 360) % 360;
|
||||||
const settings = {
|
const settings = {
|
||||||
angle,
|
angle: backendAngle,
|
||||||
horizontal: flipH,
|
horizontal: flipH,
|
||||||
vertical: flipV,
|
vertical: flipV,
|
||||||
};
|
};
|
||||||
@@ -51,52 +68,78 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
const hasChanges = angle !== 0 || flipH || flipV;
|
const hasChanges = totalAngle !== 0 || flipH || flipV;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (hasFile && hasChanges && !processing) handleProcess();
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Quick rotate buttons */}
|
{/* Quick rotate */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground">Quick Rotate</label>
|
<label className="text-xs text-muted-foreground">Rotate</label>
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={rotateLeft}
|
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" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
90 Left
|
Left
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={rotateRight}
|
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" />
|
<RotateCw className="h-4 w-4" />
|
||||||
90 Right
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Angle slider */}
|
{/* Straighten */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="text-xs text-muted-foreground">Angle</label>
|
<label className="text-xs text-muted-foreground">Straighten</label>
|
||||||
<span className="text-xs font-mono text-foreground">{angle} deg</span>
|
<span className="text-xs font-mono tabular-nums text-muted-foreground">
|
||||||
|
{straighten > 0 ? "+" : ""}
|
||||||
|
{straighten}°
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={-45}
|
||||||
max={360}
|
max={45}
|
||||||
value={angle}
|
step={0.5}
|
||||||
onChange={(e) => setAngle(Number(e.target.value))}
|
value={straighten}
|
||||||
|
onChange={(e) => setStraighten(Number(e.target.value))}
|
||||||
className="w-full mt-1"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Flip buttons */}
|
{/* Flip buttons */}
|
||||||
@@ -106,7 +149,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFlipH(!flipH)}
|
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
|
flipH
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||||
@@ -118,7 +161,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFlipV(!flipV)}
|
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
|
flipV
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||||
@@ -130,6 +173,17 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{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"}
|
{files.length > 1 ? `Apply (${files.length} files)` : "Apply"}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer() as ArrayBuffer);
|
const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer() as ArrayBuffer);
|
||||||
const extracted = unzipSync(zipBuffer);
|
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 entries = useFileStore.getState().entries;
|
||||||
const extractedNames = Object.keys(extracted);
|
const extractedNames = Object.keys(extracted);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { ImageViewer } from "@/components/common/image-viewer";
|
import { ImageViewer } from "@/components/common/image-viewer";
|
||||||
|
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
|
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
|
||||||
import * as icons from "lucide-react";
|
import * as icons from "lucide-react";
|
||||||
@@ -124,8 +125,10 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel: Image preview */}
|
{/* Right panel: Image preview */}
|
||||||
<div className="flex-1 flex items-center justify-center p-6">
|
<div className="flex-1 flex items-center justify-center p-6 min-h-0">
|
||||||
{originalBlobUrl ? (
|
{files.length > 1 ? (
|
||||||
|
<MultiImageViewer />
|
||||||
|
) : originalBlobUrl ? (
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={originalBlobUrl}
|
src={originalBlobUrl}
|
||||||
filename={selectedFileName ?? files[0].name}
|
filename={selectedFileName ?? files[0].name}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { Crop } from "react-image-crop";
|
|||||||
import { TOOLS } from "@stirling-image/shared";
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { Dropzone } from "@/components/common/dropzone";
|
import { Dropzone } from "@/components/common/dropzone";
|
||||||
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
|
||||||
import { ImageViewer } from "@/components/common/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 { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||||
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
|
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
|
||||||
import { ReviewPanel } from "@/components/common/review-panel";
|
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 { EraseObjectSettings } from "@/components/tools/erase-object-settings";
|
||||||
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
|
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
|
||||||
import * as icons from "lucide-react";
|
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([
|
const COLOR_TOOL_IDS = new Set([
|
||||||
"brightness-contrast",
|
"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)
|
// Tools that don't need a file dropzone (they generate content or have custom UI)
|
||||||
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
|
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 LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
|
||||||
|
const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]);
|
||||||
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
|
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
|
||||||
|
|
||||||
function ToolSettingsPanel({
|
function ToolSettingsPanel({
|
||||||
@@ -194,8 +195,20 @@ export function ToolPage() {
|
|||||||
undoProcessing,
|
undoProcessing,
|
||||||
batchZipBlob,
|
batchZipBlob,
|
||||||
batchZipFilename,
|
batchZipFilename,
|
||||||
|
selectedIndex,
|
||||||
|
setSelectedIndex,
|
||||||
|
navigateNext,
|
||||||
|
navigatePrev,
|
||||||
} = useFileStore();
|
} = useFileStore();
|
||||||
const isMobile = useMobile();
|
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 [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
|
||||||
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
|
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
|
||||||
|
|
||||||
@@ -367,13 +380,17 @@ export function ToolPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main area: Dropzone / Image Viewer / Before-After */}
|
{/* Main area: Dropzone / Image Viewer / Before-After */}
|
||||||
<div className="flex-1 flex items-center justify-center p-4">
|
<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 ? (
|
{isNoDropzone ? (
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
<p className="text-sm">Configure settings and generate.</p>
|
<p className="text-sm">Configure settings and generate.</p>
|
||||||
</div>
|
</div>
|
||||||
) : files.length > 1 ? (
|
|
||||||
<MultiImageViewer />
|
|
||||||
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
|
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
|
||||||
<CropCanvas
|
<CropCanvas
|
||||||
imageSrc={originalBlobUrl}
|
imageSrc={originalBlobUrl}
|
||||||
@@ -397,6 +414,12 @@ export function ToolPage() {
|
|||||||
filename={processedFileName}
|
filename={processedFileName}
|
||||||
fileSize={processedSize ?? 0}
|
fileSize={processedSize ?? 0}
|
||||||
/>
|
/>
|
||||||
|
) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
|
||||||
|
<ImageViewer
|
||||||
|
src={processedUrl}
|
||||||
|
filename={processedFileName}
|
||||||
|
fileSize={processedSize ?? 0}
|
||||||
|
/>
|
||||||
) : hasProcessed && originalBlobUrl ? (
|
) : hasProcessed && originalBlobUrl ? (
|
||||||
<BeforeAfterSlider
|
<BeforeAfterSlider
|
||||||
beforeSrc={originalBlobUrl}
|
beforeSrc={originalBlobUrl}
|
||||||
@@ -425,6 +448,20 @@ export function ToolPage() {
|
|||||||
currentFiles={files}
|
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>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
@@ -510,13 +547,17 @@ export function ToolPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main area: Dropzone / Image Viewer / Before-After */}
|
{/* Main area: Dropzone / Image Viewer / Before-After */}
|
||||||
<div className="flex-1 flex items-center justify-center p-6">
|
<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 ? (
|
{isNoDropzone ? (
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
<p className="text-sm">Configure settings and generate.</p>
|
<p className="text-sm">Configure settings and generate.</p>
|
||||||
</div>
|
</div>
|
||||||
) : files.length > 1 ? (
|
|
||||||
<MultiImageViewer />
|
|
||||||
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
|
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
|
||||||
<CropCanvas
|
<CropCanvas
|
||||||
imageSrc={originalBlobUrl}
|
imageSrc={originalBlobUrl}
|
||||||
@@ -540,6 +581,12 @@ export function ToolPage() {
|
|||||||
filename={processedFileName}
|
filename={processedFileName}
|
||||||
fileSize={processedSize ?? 0}
|
fileSize={processedSize ?? 0}
|
||||||
/>
|
/>
|
||||||
|
) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
|
||||||
|
<ImageViewer
|
||||||
|
src={processedUrl}
|
||||||
|
filename={processedFileName}
|
||||||
|
fileSize={processedSize ?? 0}
|
||||||
|
/>
|
||||||
) : hasProcessed && originalBlobUrl ? (
|
) : hasProcessed && originalBlobUrl ? (
|
||||||
<BeforeAfterSlider
|
<BeforeAfterSlider
|
||||||
beforeSrc={originalBlobUrl}
|
beforeSrc={originalBlobUrl}
|
||||||
@@ -568,6 +615,20 @@ export function ToolPage() {
|
|||||||
currentFiles={files}
|
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>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
+6
-6
@@ -6,7 +6,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Build the frontend (Vite + React)
|
# 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
|
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
|
# 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
|
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") && \
|
(/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
|
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)
|
# Pre-download ALL AI model weights into the image (no first-use download delays)
|
||||||
# This makes the Docker image fully self-contained — works offline
|
# This makes the Docker image fully self-contained — works offline
|
||||||
COPY docker/download_models.py /tmp/download_models.py
|
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)
|
# Install production dependencies (tsx is now in prod deps)
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
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 source code for API (tsx runs TS directly - no build step needed)
|
||||||
COPY apps/api/src ./apps/api/src
|
COPY apps/api/src ./apps/api/src
|
||||||
COPY apps/api/drizzle ./apps/api/drizzle
|
COPY apps/api/drizzle ./apps/api/drizzle
|
||||||
|
|||||||
Generated
+2
-76
@@ -74,12 +74,6 @@ importers:
|
|||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: ^8.1.0
|
specifier: ^8.1.0
|
||||||
version: 8.3.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':
|
'@stirling-image/ai':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/ai
|
version: link:../../packages/ai
|
||||||
@@ -1310,15 +1304,6 @@ packages:
|
|||||||
'@fastify/static@8.3.0':
|
'@fastify/static@8.3.0':
|
||||||
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
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':
|
'@iconify-json/simple-icons@1.2.74':
|
||||||
resolution: {integrity: sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA==}
|
resolution: {integrity: sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA==}
|
||||||
|
|
||||||
@@ -2829,10 +2814,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
content-disposition@1.0.1:
|
|
||||||
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
conventional-changelog-angular@8.3.0:
|
conventional-changelog-angular@8.3.0:
|
||||||
resolution: {integrity: sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==}
|
resolution: {integrity: sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==}
|
||||||
engines: {node: '>=18'}
|
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
|
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
|
hasBin: true
|
||||||
|
|
||||||
glob@13.0.6:
|
|
||||||
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
|
||||||
engines: {node: 18 || 20 || >=22}
|
|
||||||
|
|
||||||
global@4.4.0:
|
global@4.4.0:
|
||||||
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
|
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
|
||||||
|
|
||||||
@@ -3679,10 +3656,6 @@ packages:
|
|||||||
json-schema-ref-resolver@3.0.0:
|
json-schema-ref-resolver@3.0.0:
|
||||||
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
|
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:
|
json-schema-traverse@1.0.0:
|
||||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||||
|
|
||||||
@@ -4122,9 +4095,6 @@ packages:
|
|||||||
oniguruma-to-es@3.1.1:
|
oniguruma-to-es@3.1.1:
|
||||||
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
|
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
|
||||||
|
|
||||||
openapi-types@12.1.3:
|
|
||||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
|
||||||
|
|
||||||
p-each-series@3.0.0:
|
p-each-series@3.0.0:
|
||||||
resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==}
|
resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -6110,33 +6080,6 @@ snapshots:
|
|||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
glob: 11.1.0
|
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':
|
'@iconify-json/simple-icons@1.2.74':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
@@ -7845,8 +7788,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
content-disposition@1.0.1: {}
|
|
||||||
|
|
||||||
conventional-changelog-angular@8.3.0:
|
conventional-changelog-angular@8.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
compare-func: 2.0.0
|
compare-func: 2.0.0
|
||||||
@@ -8453,12 +8394,6 @@ snapshots:
|
|||||||
package-json-from-dist: 1.0.1
|
package-json-from-dist: 1.0.1
|
||||||
path-scurry: 2.0.2
|
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:
|
global@4.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
min-document: 2.19.2
|
min-document: 2.19.2
|
||||||
@@ -8733,14 +8668,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
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-schema-traverse@1.0.0: {}
|
||||||
|
|
||||||
json-with-bigint@3.5.8: {}
|
json-with-bigint@3.5.8: {}
|
||||||
@@ -9083,8 +9010,6 @@ snapshots:
|
|||||||
regex: 6.1.0
|
regex: 6.1.0
|
||||||
regex-recursion: 6.0.2
|
regex-recursion: 6.0.2
|
||||||
|
|
||||||
openapi-types@12.1.3: {}
|
|
||||||
|
|
||||||
p-each-series@3.0.0: {}
|
p-each-series@3.0.0: {}
|
||||||
|
|
||||||
p-event@6.0.1:
|
p-event@6.0.1:
|
||||||
@@ -10301,7 +10226,8 @@ snapshots:
|
|||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yaml@2.8.3: {}
|
yaml@2.8.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
yargs-parser@18.1.3:
|
yargs-parser@18.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user