mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: lightweight Docker image without AI/ML tools (:lite tag)
Closes #1
This commit is contained in:
@@ -80,8 +80,11 @@ jobs:
|
||||
- run: pnpm build
|
||||
|
||||
docker:
|
||||
name: Docker Build Test
|
||||
name: Docker Build Test (${{ matrix.variant }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [full, lite]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -92,6 +95,7 @@ jobs:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: false
|
||||
tags: stirling-image:ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: VARIANT=${{ matrix.variant }}
|
||||
tags: stirling-image:ci-${{ matrix.variant }}
|
||||
cache-from: type=gha,scope=${{ matrix.variant }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
|
||||
|
||||
@@ -49,10 +49,18 @@ jobs:
|
||||
fi
|
||||
|
||||
docker:
|
||||
name: Build and Push Docker Image
|
||||
name: Docker (${{ matrix.variant }})
|
||||
needs: release
|
||||
if: needs.release.outputs.new_version != ''
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [full, lite]
|
||||
include:
|
||||
- variant: full
|
||||
suffix: ""
|
||||
- variant: lite
|
||||
suffix: "-lite"
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v4
|
||||
@@ -86,10 +94,10 @@ jobs:
|
||||
stirlingimage/stirling-image
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.release.outputs.new_version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.release.outputs.new_version }}
|
||||
type=semver,pattern={{major}},value=v${{ needs.release.outputs.new_version }}
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
|
||||
type=semver,pattern={{major}}.{{minor}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
|
||||
type=semver,pattern={{major}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
|
||||
type=raw,value=${{ matrix.variant == 'full' && 'latest' || 'lite' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -97,8 +105,9 @@ jobs:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
build-args: VARIANT=${{ matrix.variant }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.variant }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* GET /api/v1/settings/:key — Get a specific setting
|
||||
*/
|
||||
|
||||
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db, schema } from "../db/index.js";
|
||||
@@ -26,7 +27,10 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
settings[row.key] = row.value;
|
||||
}
|
||||
|
||||
return reply.send({ settings });
|
||||
const variant = process.env.STIRLING_VARIANT === "lite" ? "lite" : "full";
|
||||
const variantUnavailableTools = variant === "lite" ? [...PYTHON_SIDECAR_TOOLS] : [];
|
||||
|
||||
return reply.send({ settings, variant, variantUnavailableTools });
|
||||
});
|
||||
|
||||
// PUT /api/v1/settings — Save settings (admin only)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@stirling-image/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { db, schema } from "../../db/index.js";
|
||||
@@ -66,6 +66,10 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Build skip set
|
||||
const skipTools = new Set([...disabledTools, ...(enableExperimental ? [] : experimentalToolIds)]);
|
||||
|
||||
// In lite mode, register 501 stubs for AI tools instead of real handlers
|
||||
const isLite = process.env.STIRLING_VARIANT === "lite";
|
||||
const liteStubTools = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
||||
|
||||
const toolRegistrations: Array<{
|
||||
id: string;
|
||||
register: (app: FastifyInstance) => void;
|
||||
@@ -121,17 +125,32 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
];
|
||||
|
||||
let skipped = 0;
|
||||
let stubbed = 0;
|
||||
for (const { id, register } of toolRegistrations) {
|
||||
if (skipTools.has(id)) {
|
||||
app.log.info(`Skipping disabled/experimental tool: ${id}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isLite && liteStubTools.has(id)) {
|
||||
// Register a 501 stub instead of the real handler
|
||||
app.post(`/api/v1/tools/${id}`, async (_request, reply) => {
|
||||
return reply.status(501).send({
|
||||
statusCode: 501,
|
||||
error: "Not Available",
|
||||
message: `The "${id}" tool requires the full image. Pull stirlingimage/stirling-image:latest for all features.`,
|
||||
});
|
||||
});
|
||||
stubbed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
register(app);
|
||||
}
|
||||
|
||||
const registered = toolRegistrations.length - skipped;
|
||||
const registered = toolRegistrations.length - skipped - stubbed;
|
||||
app.log.info(
|
||||
`Tool routes registered (${registered}/${toolRegistrations.length} tools, ${skipped} skipped)`,
|
||||
`Tool routes: ${registered} active, ${stubbed} lite-stubbed, ${skipped} skipped (${toolRegistrations.length} total)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export default defineConfig({
|
||||
{ text: "Configuration", link: "/guide/configuration" },
|
||||
{ text: "Database", link: "/guide/database" },
|
||||
{ text: "Deployment", link: "/guide/deployment" },
|
||||
{ text: "Docker tags", link: "/guide/docker-tags" },
|
||||
{ text: "Developer guide", link: "/guide/developer" },
|
||||
{ text: "Translation guide", link: "/guide/translations" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Docker Image Tags
|
||||
|
||||
Stirling Image ships two Docker image variants to fit different use cases.
|
||||
|
||||
## Full (default)
|
||||
|
||||
```bash
|
||||
docker pull stirlingimage/stirling-image:latest
|
||||
```
|
||||
|
||||
Includes all tools: image processing, AI-powered background removal, upscaling, face blurring, object erasing, and OCR. Size is ~11 GB due to bundled ML models.
|
||||
|
||||
## Lite
|
||||
|
||||
```bash
|
||||
docker pull stirlingimage/stirling-image:lite
|
||||
```
|
||||
|
||||
Includes all image processing tools (resize, crop, rotate, convert, compress, watermark, collage, and 20+ more) but excludes AI/ML tools. Size is ~1-2 GB.
|
||||
|
||||
Use this if you:
|
||||
- Only need standard image processing (no AI features)
|
||||
- Are running on constrained hardware (Raspberry Pi, small VPS)
|
||||
- Want faster pulls and smaller disk footprint
|
||||
|
||||
### Tools excluded from lite
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| Remove Background | AI-powered background removal |
|
||||
| Upscale | AI super-resolution upscaling |
|
||||
| Blur Faces | AI face detection and blurring |
|
||||
| Erase Object | AI inpainting to remove objects |
|
||||
| OCR | Optical character recognition |
|
||||
|
||||
All other tools (27+) work identically in both variants.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
### Full
|
||||
|
||||
```yaml
|
||||
services:
|
||||
stirling-image:
|
||||
image: stirlingimage/stirling-image:latest
|
||||
ports:
|
||||
- "1349:1349"
|
||||
volumes:
|
||||
- stirling-data:/data
|
||||
- stirling-workspace:/tmp/workspace
|
||||
|
||||
volumes:
|
||||
stirling-data:
|
||||
stirling-workspace:
|
||||
```
|
||||
|
||||
### Lite
|
||||
|
||||
```yaml
|
||||
services:
|
||||
stirling-image:
|
||||
image: stirlingimage/stirling-image:lite
|
||||
ports:
|
||||
- "1349:1349"
|
||||
volumes:
|
||||
- stirling-data:/data
|
||||
- stirling-workspace:/tmp/workspace
|
||||
|
||||
volumes:
|
||||
stirling-data:
|
||||
stirling-workspace:
|
||||
```
|
||||
|
||||
## Switching from lite to full
|
||||
|
||||
To upgrade from lite to full and unlock AI tools:
|
||||
|
||||
1. Stop your container
|
||||
2. Pull the full image: `docker pull stirlingimage/stirling-image:latest`
|
||||
3. Update your compose file or run command to use `:latest` instead of `:lite`
|
||||
4. Start the container
|
||||
|
||||
Your data and settings are preserved in the volumes.
|
||||
|
||||
## Version pinning
|
||||
|
||||
Both variants support semver tags for pinning:
|
||||
|
||||
| Tag | Description |
|
||||
|-----|------------|
|
||||
| `latest` | Latest full release |
|
||||
| `lite` | Latest lite release |
|
||||
| `1.6.0` | Exact full version |
|
||||
| `1.6.0-lite` | Exact lite version |
|
||||
| `1.6` | Latest patch in 1.6.x (full) |
|
||||
| `1.6-lite` | Latest patch in 1.6.x (lite) |
|
||||
@@ -20,6 +20,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { AutomatePage } from "./pages/automate-page";
|
||||
@@ -94,6 +95,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Toaster position="bottom-right" />
|
||||
<BrowserRouter>
|
||||
<KeyboardShortcutProvider>
|
||||
<AuthGuard>
|
||||
|
||||
@@ -1,17 +1,58 @@
|
||||
import type { Tool } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import { FileImage, Star } from "lucide-react";
|
||||
import { FileImage, Sparkles, Star } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: Tool;
|
||||
variantUnavailable?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCard({ tool }: ToolCardProps) {
|
||||
export function ToolCard({ tool, variantUnavailable }: ToolCardProps) {
|
||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
||||
const IconComponent = iconsMap[tool.icon] || FileImage;
|
||||
|
||||
if (variantUnavailable) {
|
||||
return (
|
||||
<div className="group flex items-center gap-3 relative">
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
|
||||
title="Add to favourites"
|
||||
>
|
||||
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast("This tool requires the full image.", {
|
||||
description:
|
||||
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
|
||||
action: {
|
||||
label: "Learn more",
|
||||
onClick: () =>
|
||||
window.open(
|
||||
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
|
||||
"_blank",
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors hover:bg-muted/50 opacity-50 cursor-pointer"
|
||||
>
|
||||
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{tool.name}</span>
|
||||
<span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
AI
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 relative">
|
||||
<button
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { SearchBar } from "../common/search-bar";
|
||||
import { ToolCard } from "../common/tool-card";
|
||||
|
||||
export function ToolPanel() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [disabledTools, setDisabledTools] = useState<string[]>([]);
|
||||
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
|
||||
const { disabledTools, experimentalEnabled, variantUnavailableTools, loaded, fetch } =
|
||||
useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
.then((data) => {
|
||||
setDisabledTools(
|
||||
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||
);
|
||||
setExperimentalEnabled(data.settings.enableExperimentalTools === "true");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
fetch();
|
||||
}, [fetch]);
|
||||
|
||||
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
|
||||
|
||||
const visibleTools = useMemo(() => {
|
||||
if (!loaded) return [];
|
||||
return TOOLS.filter((t) => {
|
||||
if (disabledTools.includes(t.id)) return false;
|
||||
if (t.experimental && !experimentalEnabled) return false;
|
||||
return true;
|
||||
});
|
||||
}, [disabledTools, experimentalEnabled]);
|
||||
}, [disabledTools, experimentalEnabled, loaded]);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!search) return visibleTools;
|
||||
@@ -59,7 +55,11 @@ export function ToolPanel() {
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{groupedTools.get(category.id)?.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
variantUnavailable={unavailableSet.has(tool.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
@@ -29,13 +30,7 @@ const IDLE_PROGRESS: ToolProgress = {
|
||||
|
||||
// AI tools that go through Python/bridge.ts and can emit SSE progress.
|
||||
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
|
||||
const AI_PYTHON_TOOLS = new Set([
|
||||
"remove-background",
|
||||
"upscale",
|
||||
"blur-faces",
|
||||
"erase-object",
|
||||
"ocr",
|
||||
]);
|
||||
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
||||
|
||||
export function useToolProcessor(toolId: string) {
|
||||
const {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
// Tools shown prominently as "quick actions" at the top
|
||||
const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"];
|
||||
@@ -15,6 +17,13 @@ export function HomePage() {
|
||||
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } =
|
||||
useFileStore();
|
||||
const navigate = useNavigate();
|
||||
const { variantUnavailableTools, fetch: fetchSettings } = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
@@ -24,8 +33,22 @@ export function HomePage() {
|
||||
[setFiles, reset],
|
||||
);
|
||||
|
||||
const handleToolClick = (route: string) => {
|
||||
// Files are already in the store — just navigate
|
||||
const handleToolClick = (route: string, toolId: string) => {
|
||||
if (unavailableSet.has(toolId)) {
|
||||
toast("This tool requires the full image.", {
|
||||
description:
|
||||
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
|
||||
action: {
|
||||
label: "Learn more",
|
||||
onClick: () =>
|
||||
window.open(
|
||||
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
|
||||
"_blank",
|
||||
),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate(route);
|
||||
};
|
||||
|
||||
@@ -80,8 +103,11 @@ export function HomePage() {
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleToolClick(tool.route)}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||
onClick={() => handleToolClick(tool.route, tool.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left",
|
||||
unavailableSet.has(id) && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -122,10 +148,12 @@ export function HomePage() {
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => handleToolClick(tool.route)}
|
||||
onClick={() => handleToolClick(tool.route, tool.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
|
||||
"hover:bg-muted text-foreground",
|
||||
unavailableSet.has(tool.id)
|
||||
? "opacity-50 hover:bg-muted/50"
|
||||
: "hover:bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from "zustand";
|
||||
import { apiGet } from "@/lib/api";
|
||||
|
||||
interface SettingsState {
|
||||
variant: "full" | "lite";
|
||||
variantUnavailableTools: string[];
|
||||
disabledTools: string[];
|
||||
experimentalEnabled: boolean;
|
||||
loaded: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
variant: "full",
|
||||
variantUnavailableTools: [],
|
||||
disabledTools: [],
|
||||
experimentalEnabled: false,
|
||||
loaded: false,
|
||||
|
||||
fetch: async () => {
|
||||
if (get().loaded) return;
|
||||
try {
|
||||
const data = await apiGet<{
|
||||
settings: Record<string, string>;
|
||||
variant: "full" | "lite";
|
||||
variantUnavailableTools: string[];
|
||||
}>("/v1/settings");
|
||||
|
||||
set({
|
||||
variant: data.variant ?? "full",
|
||||
variantUnavailableTools: data.variantUnavailableTools ?? [],
|
||||
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||
experimentalEnabled: data.settings.enableExperimentalTools === "true",
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
// Settings fetch failed - default to full with no disabled tools
|
||||
set({ loaded: true });
|
||||
}
|
||||
},
|
||||
}));
|
||||
+37
-27
@@ -4,6 +4,8 @@
|
||||
# Multi-stage build for single-container deployment
|
||||
# ============================================
|
||||
|
||||
ARG VARIANT=full
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Build the frontend (Vite + React)
|
||||
# ============================================
|
||||
@@ -40,52 +42,56 @@ RUN --mount=type=cache,id=turbo-cache,target=/app/.turbo \
|
||||
# ============================================
|
||||
FROM node:22-bookworm AS production
|
||||
|
||||
ARG VARIANT
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
# Install system dependencies for image processing and AI
|
||||
# System dependencies shared by all variants
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv python3-dev \
|
||||
imagemagick \
|
||||
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa \
|
||||
libraw-dev \
|
||||
potrace \
|
||||
curl \
|
||||
build-essential \
|
||||
libgl1 libglib2.0-0 \
|
||||
gosu \
|
||||
libheif-examples \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create Python venv and install ML packages
|
||||
RUN python3 -m venv /opt/venv
|
||||
# Python/ML system dependencies (full variant only)
|
||||
RUN if [ "$VARIANT" = "full" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv python3-dev \
|
||||
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa \
|
||||
build-essential \
|
||||
libgl1 libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
; fi
|
||||
|
||||
# Python venv + ML packages + model weights (full variant only)
|
||||
COPY packages/ai/python/requirements.txt /tmp/requirements.txt
|
||||
|
||||
# Install Python packages - fail loudly for critical ones, warn for optional
|
||||
RUN --mount=type=cache,id=pip-cache,target=/root/.cache/pip \
|
||||
RUN if [ "$VARIANT" = "full" ]; then \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip && \
|
||||
/opt/venv/bin/pip install \
|
||||
Pillow numpy opencv-python-headless onnxruntime && \
|
||||
(/opt/venv/bin/pip install rembg[cpu] || echo "WARNING: rembg not installed - background removal will be unavailable") && \
|
||||
(/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed - will fallback to Lanczos upscaling") && \
|
||||
(/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed - will fallback to Tesseract") && \
|
||||
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed - face detection will be unavailable") && \
|
||||
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed - object eraser will be unavailable") && \
|
||||
rm /tmp/requirements.txt
|
||||
Pillow numpy opencv-python-headless onnxruntime && \
|
||||
(/opt/venv/bin/pip install "rembg[cpu]" || echo "WARNING: rembg not installed") && \
|
||||
(/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed") && \
|
||||
(/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed") && \
|
||||
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
|
||||
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \
|
||||
; fi && rm -f /tmp/requirements.txt
|
||||
|
||||
# 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
|
||||
RUN /opt/venv/bin/python3 /tmp/download_models.py && rm /tmp/download_models.py
|
||||
|
||||
RUN /opt/venv/bin/python3 -c "\
|
||||
RUN if [ "$VARIANT" = "full" ]; then \
|
||||
/opt/venv/bin/python3 /tmp/download_models.py && \
|
||||
/opt/venv/bin/python3 -c "\
|
||||
try: \
|
||||
from paddleocr import PaddleOCR; \
|
||||
print('Downloading PaddleOCR models...'); \
|
||||
ocr = PaddleOCR(use_angle_cls=True, lang='en', show_log=False); \
|
||||
print('PaddleOCR models ready'); \
|
||||
except: print('PaddleOCR model pre-download skipped') \
|
||||
" 2>/dev/null || echo "WARNING: Could not pre-download PaddleOCR models"
|
||||
" 2>/dev/null || echo "WARNING: Could not pre-download PaddleOCR models" \
|
||||
; fi && rm -f /tmp/download_models.py
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -105,8 +111,10 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store/v3 \
|
||||
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/*
|
||||
RUN if [ "$VARIANT" = "full" ]; then \
|
||||
apt-get purge -y --auto-remove build-essential python3-dev && \
|
||||
rm -rf /var/lib/apt/lists/* \
|
||||
; fi
|
||||
|
||||
# Copy source code for API (tsx runs TS directly - no build step needed)
|
||||
COPY apps/api/src ./apps/api/src
|
||||
@@ -143,11 +151,13 @@ ENV PORT=1349 \
|
||||
MAX_BATCH_SIZE=200 \
|
||||
CONCURRENT_JOBS=3 \
|
||||
MAX_MEGAPIXELS=100 \
|
||||
RATE_LIMIT_PER_MIN=100
|
||||
RATE_LIMIT_PER_MIN=100 \
|
||||
STIRLING_VARIANT=${VARIANT}
|
||||
|
||||
# Create non-root user for runtime
|
||||
RUN groupadd -r stirling && useradd -r -g stirling -d /app -s /sbin/nologin stirling
|
||||
RUN chown -R stirling:stirling /app /data /tmp/workspace /opt/venv
|
||||
RUN chown -R stirling:stirling /app /data /tmp/workspace && \
|
||||
([ -d /opt/venv ] && chown -R stirling:stirling /opt/venv || true)
|
||||
|
||||
# Entrypoint fixes volume permissions then drops to stirling via gosu
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
# Lightweight Docker Image Without AI/ML Tools
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Issue:** stirling-image/stirling-image#1
|
||||
**Status:** Design approved
|
||||
|
||||
## Problem
|
||||
|
||||
The full Docker image is ~11 GB, mostly Python ML dependencies (rembg, RealESRGAN, PaddleOCR, MediaPipe, LaMa) and pre-downloaded model weights. Users on constrained hardware (Raspberry Pi, small VPS) or those who only need image processing tools are paying for size they don't use. First community feedback on r/selfhosted flagged this.
|
||||
|
||||
## Solution
|
||||
|
||||
Ship a `:lite` Docker tag that drops the Python sidecar and all ML dependencies. Keep every Sharp-based tool. Target size: 1-2 GB.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Build strategy | Single Dockerfile, `ARG VARIANT=full` | One file to maintain. Avoids drift between two Dockerfiles. |
|
||||
| Detection mechanism | Build-time `ENV STIRLING_VARIANT` | Explicit, instant, testable. No startup probing. |
|
||||
| API behavior (lite) | AI routes return 501 | Clear signal vs confusing 404. Tells consumers what to do. |
|
||||
| Frontend-API bridge | Extend `/v1/settings` response | Reuses existing fetch. Avoids extra endpoint complexity. |
|
||||
| Frontend UX | Grey out AI tools + "AI" badge + toast on click | Users see what they're missing. Toast links to docs for upgrade path. |
|
||||
| Tag naming | `:lite` / `:latest` (full) | "lite" = fewer features (accurate). "slim" = smaller OS base (misleading in Docker convention). |
|
||||
| Feature scope | All Sharp tools stay, 5 Python tools dropped | Sharp tools add zero meaningful size. All savings come from Python. |
|
||||
| Shared constants | `PYTHON_SIDECAR_TOOLS` in `packages/shared/` | Single source of truth for AI tool IDs across API and frontend. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dockerfile (`docker/Dockerfile`)
|
||||
|
||||
A build arg controls the variant, defaulting to `full`:
|
||||
|
||||
```dockerfile
|
||||
ARG VARIANT=full
|
||||
```
|
||||
|
||||
In the production stage, the Python installation block (system packages, venv, pip installs, model downloads) is wrapped in a shell conditional:
|
||||
|
||||
```dockerfile
|
||||
ARG VARIANT
|
||||
RUN if [ "$VARIANT" = "full" ]; then \
|
||||
apt-get install -y python3 python3-pip python3-venv python3-dev \
|
||||
tesseract-ocr tesseract-ocr-deu tesseract-ocr-fra \
|
||||
tesseract-ocr-spa tesseract-ocr-chi-sim \
|
||||
build-essential libgl1 libglib2.0-0 && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install ... && \
|
||||
python3 docker/download_models.py && \
|
||||
... model downloads ... \
|
||||
; fi
|
||||
```
|
||||
|
||||
A runtime env var is set from the build arg:
|
||||
|
||||
```dockerfile
|
||||
ENV STIRLING_VARIANT=${VARIANT}
|
||||
```
|
||||
|
||||
Packages kept in both variants (used by Sharp-based tools): imagemagick, libraw-dev, potrace, libheif-examples, gosu.
|
||||
|
||||
Packages dropped in lite: python3, python3-pip, python3-venv, python3-dev, tesseract-ocr (+ language packs), build-essential, libgl1, libglib2.0-0.
|
||||
|
||||
Base image stays `node:22-bookworm` for both variants. Switching lite to `bookworm-slim` is a future optimization, not in scope for the first pass.
|
||||
|
||||
Building the lite image: `docker build --build-arg VARIANT=lite -t stirling-image:lite .`
|
||||
|
||||
### Shared Constants (`packages/shared/`)
|
||||
|
||||
A new constant in `packages/shared/src/constants.ts`:
|
||||
|
||||
```typescript
|
||||
export const PYTHON_SIDECAR_TOOLS = [
|
||||
"remove-background",
|
||||
"upscale",
|
||||
"blur-faces",
|
||||
"erase-object",
|
||||
"ocr",
|
||||
] as const;
|
||||
```
|
||||
|
||||
This replaces the hardcoded `AI_PYTHON_TOOLS` set in `apps/web/src/hooks/use-tool-processor.ts` and is used by the API for route registration and settings response.
|
||||
|
||||
### API Changes (`apps/api/`)
|
||||
|
||||
**Route registration** (`apps/api/src/routes/tools/index.ts`):
|
||||
|
||||
When `STIRLING_VARIANT === "lite"`, the 5 AI tool routes are registered as lightweight stub handlers returning 501. The `@stirling-image/ai` package is not imported at all in lite mode (conditional import), avoiding any accidental Python spawn attempt.
|
||||
|
||||
```typescript
|
||||
if (process.env.STIRLING_VARIANT !== "lite") {
|
||||
// Register actual AI tool routes (import @stirling-image/ai)
|
||||
} else {
|
||||
// Register stub routes returning 501 for each PYTHON_SIDECAR_TOOLS entry
|
||||
}
|
||||
```
|
||||
|
||||
The 501 response:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 501,
|
||||
"error": "Not Available",
|
||||
"message": "This tool requires the full image. See docs at <link>"
|
||||
}
|
||||
```
|
||||
|
||||
**Settings endpoint** (`/v1/settings` response):
|
||||
|
||||
Two new fields, derived at startup from `process.env.STIRLING_VARIANT` and the shared constant. Not stored in the database.
|
||||
|
||||
```json
|
||||
{
|
||||
"...existing settings...",
|
||||
"variant": "lite",
|
||||
"variantUnavailableTools": ["remove-background", "upscale", "blur-faces", "erase-object", "ocr"]
|
||||
}
|
||||
```
|
||||
|
||||
In `full` mode: `variant: "full"`, `variantUnavailableTools: []`.
|
||||
|
||||
### Frontend Changes (`apps/web/`)
|
||||
|
||||
**Settings state**: Variant info is fetched once via a shared Zustand store (or shared hook) so both `ToolPanel` and `HomePage` access it without duplicate requests.
|
||||
|
||||
**Tool rendering**: Tools listed in `variantUnavailableTools` are rendered greyed out with an "AI" badge. Clicking shows a toast: "This tool requires the full image" with a link to the docs page.
|
||||
|
||||
This is distinct from user-disabled tools (`disabledTools`), which are hidden entirely with no toast.
|
||||
|
||||
**`use-tool-processor.ts`**: Replaces the hardcoded `AI_PYTHON_TOOLS` set with the shared `PYTHON_SIDECAR_TOOLS` constant import.
|
||||
|
||||
### CI/CD Changes (`.github/workflows/release.yml`)
|
||||
|
||||
The Docker build job uses a matrix strategy:
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [full, lite]
|
||||
```
|
||||
|
||||
Tags published per variant:
|
||||
|
||||
| Variant | Tags |
|
||||
|---------|------|
|
||||
| full | `latest`, `1.6.0`, `1.6`, `1` |
|
||||
| lite | `lite`, `1.6.0-lite`, `1.6-lite`, `1-lite` |
|
||||
|
||||
Both variants are multi-arch (`linux/amd64,linux/arm64`) and pushed to Docker Hub (`stirlingimage/stirling-image`) and GHCR (`ghcr.io/stirling-image/stirling-image`).
|
||||
|
||||
The CI workflow (`ci.yml`) also builds both variants as a smoke test (build only, no push).
|
||||
|
||||
### Documentation (`apps/docs/`)
|
||||
|
||||
A new docs page covering:
|
||||
|
||||
- What the lite image is and why it exists
|
||||
- Which tools are included vs excluded (the 5 AI tools)
|
||||
- Pull commands: `docker pull stirlingimage/stirling-image:lite`
|
||||
- Docker Compose examples for both variants
|
||||
- How to switch from lite to full when AI tools are needed
|
||||
|
||||
This is the page linked from the frontend toast and the 501 API response.
|
||||
|
||||
## Tools by Variant
|
||||
|
||||
### Included in lite (all Sharp-based, ~27 tools)
|
||||
|
||||
resize, crop, rotate, convert, compress, strip-metadata, color-adjustments, watermark-text, watermark-image, text-overlay, compose, info, compare, find-duplicates, color-palette, qr-generate, barcode-read, collage, split, border, svg-to-raster, vectorize, gif-tools, bulk-rename, favicon, image-to-pdf, replace-color, smart-crop
|
||||
|
||||
### Excluded from lite (Python sidecar required, 5 tools)
|
||||
|
||||
remove-background, upscale, blur-faces, erase-object, ocr
|
||||
|
||||
## Testing
|
||||
|
||||
- Build both variants in CI and verify they start successfully
|
||||
- Verify lite image does not contain Python, pip, or model weights
|
||||
- Verify AI routes return 501 in lite mode
|
||||
- Verify frontend shows greyed-out AI tools with correct toast in lite mode
|
||||
- Verify all Sharp-based tools work identically in both variants
|
||||
- Verify lite image size is in the 1-2 GB target range
|
||||
@@ -355,3 +355,16 @@ export const SOCIAL_MEDIA_PRESETS: SocialMediaPreset[] = [
|
||||
];
|
||||
|
||||
export const APP_VERSION = "1.5.3";
|
||||
|
||||
/**
|
||||
* Tool IDs that require the Python sidecar (AI/ML tools).
|
||||
* Used by the API to register 501 stubs in lite mode,
|
||||
* and by the frontend for progress/timeout behavior.
|
||||
*/
|
||||
export const PYTHON_SIDECAR_TOOLS = [
|
||||
"remove-background",
|
||||
"upscale",
|
||||
"blur-faces",
|
||||
"erase-object",
|
||||
"ocr",
|
||||
] as const;
|
||||
|
||||
Generated
+14
@@ -207,6 +207,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^7.1.0
|
||||
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.1
|
||||
@@ -4929,6 +4932,12 @@ packages:
|
||||
sonic-boom@4.2.1:
|
||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -10146,6 +10155,11 @@ snapshots:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
describe("Lite variant", () => {
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.STIRLING_VARIANT = "lite";
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.STIRLING_VARIANT;
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("GET /api/v1/settings", () => {
|
||||
it("includes variant and variantUnavailableTools", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.variant).toBe("lite");
|
||||
expect(body.variantUnavailableTools).toEqual([
|
||||
"remove-background",
|
||||
"upscale",
|
||||
"blur-faces",
|
||||
"erase-object",
|
||||
"ocr",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI tool routes return 501", () => {
|
||||
const aiTools = ["remove-background", "upscale", "blur-faces", "erase-object", "ocr"];
|
||||
|
||||
for (const toolId of aiTools) {
|
||||
it(`POST /api/v1/tools/${toolId} returns 501`, async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/v1/tools/${toolId}`,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(501);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toBe("Not Available");
|
||||
expect(body.message).toContain("full image");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Sharp tools still work in lite mode", () => {
|
||||
it("POST /api/v1/tools/info returns 200 with valid image", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const __dirname = join(fileURLToPath(import.meta.url), "..");
|
||||
const png = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png"));
|
||||
|
||||
const boundary = "----TestBoundary";
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="test.png"\r\nContent-Type: image/png\r\n\r\n`,
|
||||
),
|
||||
png,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/info",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user