>;
const IconComponent = iconsMap[tool.icon] || FileImage;
if (variantUnavailable) {
return (
);
}
return (
{tool.name}
{tool.experimental && (
Experimental
)}
);
}
```
- [ ] **Step 2: Verify typecheck passes**
Run: `pnpm typecheck`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/common/tool-card.tsx
git commit -m "feat: ToolCard shows AI badge and upgrade toast for variant-unavailable tools"
```
---
### Task 6: Frontend - Update ToolPanel to Use Settings Store
**Files:**
- Modify: `apps/web/src/components/layout/tool-panel.tsx`
- [ ] **Step 1: Replace local state with settings store**
Replace the entire content of `apps/web/src/components/layout/tool-panel.tsx`:
```typescript
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
import { useEffect, useMemo, useState } from "react";
import { SearchBar } from "../common/search-bar";
import { ToolCard } from "../common/tool-card";
import { useSettingsStore } from "@/stores/settings-store";
export function ToolPanel() {
const [search, setSearch] = useState("");
const { disabledTools, experimentalEnabled, variantUnavailableTools, loaded, fetch } =
useSettingsStore();
useEffect(() => {
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, loaded]);
const filteredTools = useMemo(() => {
if (!search) return visibleTools;
const q = search.toLowerCase();
return visibleTools.filter(
(t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q),
);
}, [search, visibleTools]);
const groupedTools = useMemo(() => {
const groups = new Map();
for (const tool of filteredTools) {
const list = groups.get(tool.category) || [];
list.push(tool);
groups.set(tool.category, list);
}
return groups;
}, [filteredTools]);
return (
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
{category.name}
{groupedTools.get(category.id)?.map((tool) => (
))}
))}
{filteredTools.length === 0 && (
No tools found
)}
);
}
```
- [ ] **Step 2: Verify typecheck passes**
Run: `pnpm typecheck`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/layout/tool-panel.tsx
git commit -m "feat: ToolPanel uses settings store for variant-aware tool filtering"
```
---
### Task 7: Frontend - Update HomePage for Variant-Unavailable Tools
**Files:**
- Modify: `apps/web/src/pages/home-page.tsx`
- [ ] **Step 1: Add variant awareness to HomePage**
In `apps/web/src/pages/home-page.tsx`, add the import near the top:
```typescript
import { toast } from "sonner";
import { useSettingsStore } from "@/stores/settings-store";
```
Inside the `HomePage` component, after the existing hooks (`useFileStore`, `useNavigate`), add:
```typescript
const { variantUnavailableTools, fetch: fetchSettings } = useSettingsStore();
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const unavailableSet = useMemo(
() => new Set(variantUnavailableTools),
[variantUnavailableTools],
);
```
Add `useEffect` and `useMemo` to the existing import from `react`:
```typescript
import { useCallback, useEffect, useMemo } from "react";
```
Modify `handleToolClick` to check for variant-unavailable tools:
```typescript
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);
};
```
Update the quick actions button `onClick` (around line 83):
```typescript
onClick={() => handleToolClick(tool.route, tool.id)}
```
Add opacity styling to quick action buttons for unavailable tools (around line 84):
```typescript
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",
)}
```
Update the "All Tools" section button `onClick` (around line 125):
```typescript
onClick={() => handleToolClick(tool.route, tool.id)}
```
Add opacity styling to the all-tools buttons for unavailable tools (around line 126-129):
```typescript
className={cn(
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
unavailableSet.has(tool.id)
? "opacity-50 hover:bg-muted/50"
: "hover:bg-muted text-foreground",
)}
```
- [ ] **Step 2: Verify typecheck passes**
Run: `pnpm typecheck`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/pages/home-page.tsx
git commit -m "feat: HomePage greys out variant-unavailable tools with upgrade toast"
```
---
### Task 8: Frontend - Update use-tool-processor to Use Shared Constant
**Files:**
- Modify: `apps/web/src/hooks/use-tool-processor.ts`
- [ ] **Step 1: Replace hardcoded set with shared constant**
In `apps/web/src/hooks/use-tool-processor.ts`, add the import at the top:
```typescript
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
```
Replace lines 30-38 (the `AI_PYTHON_TOOLS` definition):
```typescript
// 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",
]);
```
With:
```typescript
// 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(PYTHON_SIDECAR_TOOLS);
```
- [ ] **Step 2: Verify typecheck passes**
Run: `pnpm typecheck`
Expected: PASS
- [ ] **Step 3: Run linter**
Run: `pnpm lint`
Expected: PASS (no unused imports, formatting OK)
- [ ] **Step 4: Commit**
```bash
git add apps/web/src/hooks/use-tool-processor.ts
git commit -m "refactor: use shared PYTHON_SIDECAR_TOOLS constant in use-tool-processor"
```
---
### Task 9: Dockerfile - Add VARIANT Build Arg
**Files:**
- Modify: `docker/Dockerfile`
- [ ] **Step 1: Add build arg and conditional Python install**
At the very top of `docker/Dockerfile`, after the comment header (line 5) and before Stage 1, add:
```dockerfile
ARG VARIANT=full
```
In the production stage (after line 41 `FROM node:22-bookworm AS production`), re-declare the arg:
```dockerfile
ARG VARIANT
```
Replace the system dependencies block (lines 46-57) with:
```dockerfile
# System dependencies shared by all variants
RUN apt-get update && apt-get install -y --no-install-recommends \
imagemagick \
libraw-dev \
potrace \
curl \
gosu \
libheif-examples \
&& rm -rf /var/lib/apt/lists/*
# 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
```
Replace the Python venv and ML install block (lines 59-88) with:
```dockerfile
# Python venv + ML packages + model weights (full variant only)
COPY packages/ai/python/requirements.txt /tmp/requirements.txt
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") && \
(/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
COPY docker/download_models.py /tmp/download_models.py
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" \
; fi && rm -f /tmp/download_models.py
```
Replace the build-essential cleanup block (lines 108-109) with:
```dockerfile
RUN if [ "$VARIANT" = "full" ]; then \
apt-get purge -y --auto-remove build-essential python3-dev && \
rm -rf /var/lib/apt/lists/* \
; fi
```
Add the variant env var to the ENV block (after `RATE_LIMIT_PER_MIN=100` on line 146):
```dockerfile
STIRLING_VARIANT=${VARIANT}
```
Update the `chown` line to handle the case where `/opt/venv` doesn't exist in lite mode. Replace line 150:
```dockerfile
RUN chown -R stirling:stirling /app /data /tmp/workspace && \
([ -d /opt/venv ] && chown -R stirling:stirling /opt/venv || true)
```
- [ ] **Step 2: Test lite build locally**
Run: `docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite-test .`
Expected: Build succeeds. No Python installation steps in the output.
- [ ] **Step 3: Test full build still works**
Run: `docker build -f docker/Dockerfile -t stirling-image:full-test .`
Expected: Build succeeds with Python/ML installation as before.
- [ ] **Step 4: Verify lite image is smaller**
Run: `docker images | grep stirling-image`
Expected: `lite-test` is ~1-2 GB, `full-test` is ~11 GB.
- [ ] **Step 5: Commit**
```bash
git add docker/Dockerfile
git commit -m "feat: add VARIANT build arg to Dockerfile for lite image support"
```
---
### Task 10: CI - Add Lite Variant Smoke Test
**Files:**
- Modify: `.github/workflows/ci.yml`
- [ ] **Step 1: Add matrix to docker job**
Replace the docker job in `.github/workflows/ci.yml` (lines 82-98) with:
```yaml
docker:
name: Docker Build Test (${{ matrix.variant }})
runs-on: ubuntu-latest
strategy:
matrix:
variant: [full, lite]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: false
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 }}
```
- [ ] **Step 2: Commit**
```bash
git add .github/workflows/ci.yml
git commit -m "ci: add matrix to build both full and lite Docker variants"
```
---
### Task 11: Release - Matrix for Publishing Both Variants
**Files:**
- Modify: `.github/workflows/release.yml`
- [ ] **Step 1: Replace single docker job with matrix**
Replace the entire `docker` job in `.github/workflows/release.yml` (lines 51-105) with:
```yaml
docker:
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
with:
ref: v${{ needs.release.outputs.new_version }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
stirlingimage/stirling-image
ghcr.io/${{ github.repository }}
tags: |
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
with:
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,scope=${{ matrix.variant }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
```
This produces for a v1.6.0 release:
| Variant | Tags |
|---------|------|
| full | `1.6.0`, `1.6`, `1`, `latest` |
| lite | `1.6.0-lite`, `1.6-lite`, `1-lite`, `lite` |
- [ ] **Step 2: Commit**
```bash
git add .github/workflows/release.yml
git commit -m "ci: publish both full and lite Docker images on release"
```
---
### Task 12: Documentation - Docker Tags Page
**Files:**
- Create: `apps/docs/guide/docker-tags.md`
- Modify: `apps/docs/.vitepress/config.mts`
- [ ] **Step 1: Create the docs page**
Create `apps/docs/guide/docker-tags.md`:
```markdown
# 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) |
```
- [ ] **Step 2: Add sidebar entry**
In `apps/docs/.vitepress/config.mts`, add an entry to the Guide sidebar items array (after the "Deployment" entry, around line 34):
```typescript
{ text: "Docker tags", link: "/guide/docker-tags" },
```
- [ ] **Step 3: Commit**
```bash
git add apps/docs/guide/docker-tags.md apps/docs/.vitepress/config.mts
git commit -m "docs: add Docker tags guide for full vs lite image"
```
---
### Task 13: Final Verification
- [ ] **Step 1: Run full test suite**
Run: `pnpm test`
Expected: All unit and integration tests PASS
- [ ] **Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: PASS
- [ ] **Step 3: Run linter**
Run: `pnpm lint`
Expected: PASS (run `pnpm lint:fix` if formatting issues)
- [ ] **Step 4: Verify lite Docker build**
Run: `docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite-verify .`
Expected: Build succeeds, no Python in image
- [ ] **Step 5: Smoke test lite container**
Run: `docker run --rm -d -p 1349:1349 --name si-lite stirling-image:lite-verify`
Verify:
- Health check passes: `curl http://localhost:1349/api/v1/health`
- Settings show lite variant: `curl -H "Authorization: Bearer " http://localhost:1349/api/v1/settings | jq .variant`
- AI route returns 501: `curl -X POST http://localhost:1349/api/v1/tools/remove-background`
Run: `docker stop si-lite`
- [ ] **Step 6: Check image size**
Run: `docker images stirling-image:lite-verify --format '{{.Size}}'`
Expected: ~1-2 GB