feat: lightweight Docker image without AI/ML tools (:lite tag)

Closes #1
This commit is contained in:
stirling-image
2026-04-05 00:23:21 +08:00
committed by GitHub
parent 51f10abb35
commit 449a2fc319
19 changed files with 1819 additions and 72 deletions
+5 -1
View File
@@ -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)
+22 -3
View File
@@ -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)`,
);
}
+1
View File
@@ -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" },
],
+96
View File
@@ -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) |
+1
View File
@@ -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"
},
+2
View File
@@ -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>
+43 -2
View File
@@ -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
+14 -14
View File
@@ -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>
+2 -7
View File
@@ -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 {
+35 -7
View File
@@ -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" />
+41
View File
@@ -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 });
}
},
}));