diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fcb0133..0083fedf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,11 +80,20 @@ jobs: - run: pnpm build docker: - name: Docker Build Test (${{ matrix.variant }}) + name: Docker Build Test (${{ matrix.tag }}) runs-on: ubuntu-latest strategy: matrix: - variant: [full, lite] + include: + - tag: full + variant: full + gpu: "false" + - tag: lite + variant: lite + gpu: "false" + - tag: cuda + variant: full + gpu: "true" steps: - uses: actions/checkout@v4 @@ -95,7 +104,9 @@ jobs: 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 }} + build-args: | + VARIANT=${{ matrix.variant }} + GPU=${{ matrix.gpu }} + tags: stirling-image:ci-${{ matrix.tag }} + cache-from: type=gha,scope=${{ matrix.tag }} + cache-to: type=gha,mode=max,scope=${{ matrix.tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1661db95..8ea46e0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,18 +49,28 @@ jobs: fi docker: - name: Docker (${{ matrix.variant }}) + name: Docker (${{ matrix.tag }}) needs: release if: needs.release.outputs.new_version != '' runs-on: ubuntu-latest strategy: matrix: - variant: [full, lite] include: - - variant: full + - tag: full + variant: full + gpu: "false" suffix: "" - - variant: lite + platforms: "linux/amd64,linux/arm64" + - tag: lite + variant: lite + gpu: "false" suffix: "-lite" + platforms: "linux/amd64,linux/arm64" + - tag: cuda + variant: full + gpu: "true" + suffix: "-cuda" + platforms: "linux/amd64" steps: - name: Checkout release tag uses: actions/checkout@v4 @@ -97,7 +107,7 @@ jobs: 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' }} + type=raw,value=${{ matrix.tag == 'full' && 'latest' || matrix.tag }} - name: Build and push uses: docker/build-push-action@v6 @@ -105,9 +115,11 @@ jobs: context: . file: docker/Dockerfile push: true - build-args: VARIANT=${{ matrix.variant }} - platforms: linux/amd64,linux/arm64 + build-args: | + VARIANT=${{ matrix.variant }} + GPU=${{ matrix.gpu }} + platforms: ${{ matrix.platforms }} 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 }} + cache-from: type=gha,scope=${{ matrix.tag }} + cache-to: type=gha,mode=max,scope=${{ matrix.tag }} diff --git a/README.md b/README.md index 517646ba..93afd3e7 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ Don't need AI tools? The lite image is 1.5 GB instead of 11 GB: docker run -d -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:lite ``` +Have an NVIDIA GPU? The CUDA image accelerates background removal, upscaling, and OCR: + +```bash +docker run -d -p 1349:1349 --gpus all -v stirling-data:/data stirlingimage/stirling-image:cuda +``` + Open http://localhost:1349 in your browser. **Default credentials:** @@ -56,7 +62,7 @@ Open http://localhost:1349 in your browser. You will be asked to change your password on first login. This is enforced for all new accounts and cannot be skipped in production. -For Docker Compose, persistent storage, and other setup options, see the [Getting Started Guide](https://stirling-image.github.io/stirling-image/guide/getting-started). For details on the full vs lite image, see [Docker Tags](https://stirling-image.github.io/stirling-image/guide/docker-tags). +For Docker Compose, persistent storage, and other setup options, see the [Getting Started Guide](https://stirling-image.github.io/stirling-image/guide/getting-started). For details on all image variants (full, lite, cuda), see [Docker Tags](https://stirling-image.github.io/stirling-image/guide/docker-tags). ## Documentation diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cc60876b..a42f6fae 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,6 @@ import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; +import { isGpuAvailable } from "@stirling-image/ai"; import { APP_VERSION } from "@stirling-image/shared"; import Fastify from "fastify"; import { env } from "./config.js"; @@ -132,7 +133,7 @@ app.get("/api/v1/admin/health", async (request, reply) => { storage: { mode: env.STORAGE_MODE, available: "N/A" }, database: dbOk ? "ok" : "error", queue: { active: 0, pending: 0 }, - ai: {}, + ai: { gpu: isGpuAvailable() }, }; }); diff --git a/apps/docs/api/ai.md b/apps/docs/api/ai.md index e6332da4..6974ad55 100644 --- a/apps/docs/api/ai.md +++ b/apps/docs/api/ai.md @@ -8,6 +8,10 @@ All model weights are bundled in the Docker image during the build. No downloads AI tools are not available in the `:lite` Docker image. The API returns `501 Not Available` for these endpoints when running the lite variant. Use `:latest` for AI features. See [Docker Tags](/guide/docker-tags) for details. ::: +::: tip GPU acceleration +The `:cuda` Docker image includes GPU-accelerated versions of the ML libraries. Background removal, upscaling, and OCR all benefit from NVIDIA GPU acceleration. The image auto-detects your GPU and falls back to CPU if none is available. See [Docker Tags](/guide/docker-tags) for setup. +::: + ## Background removal Removes the background from an image and returns a transparent PNG. diff --git a/apps/docs/guide/deployment.md b/apps/docs/guide/deployment.md index 7654cb6c..c428f139 100644 --- a/apps/docs/guide/deployment.md +++ b/apps/docs/guide/deployment.md @@ -2,12 +2,13 @@ Stirling Image ships as a single Docker container. The image supports **linux/amd64** and **linux/arm64**, so it runs natively on Intel/AMD servers, Apple Silicon Macs, and ARM devices like the Raspberry Pi 4/5. -Two variants are available: +Three variants are available: | Variant | Tag | Size | What's included | |---------|-----|------|-----------------| | Full | `:latest` | ~11 GB | All tools + AI/ML (background removal, upscaling, OCR, face blur, object eraser) | | Lite | `:lite` | ~1.5 GB | All image processing tools, no AI/ML | +| CUDA | `:cuda` | ~14 GB | Full + GPU-accelerated AI (NVIDIA only, amd64) | See [Docker Tags](./docker-tags) for the full comparison, Docker Compose examples, and version pinning. diff --git a/apps/docs/guide/docker-tags.md b/apps/docs/guide/docker-tags.md index f7835cc3..6d9489ec 100644 --- a/apps/docs/guide/docker-tags.md +++ b/apps/docs/guide/docker-tags.md @@ -1,6 +1,6 @@ # Docker Image Tags -Stirling Image ships two Docker image variants to fit different use cases. +Stirling Image ships three Docker image variants to fit different use cases. ## Full (default) @@ -35,6 +35,52 @@ Use this if you: All other tools (27+) work identically in both variants. +## CUDA (GPU acceleration) + +```bash +docker pull stirlingimage/stirling-image:cuda +``` + +Same tools as the full image, but built with GPU-accelerated Python packages (onnxruntime-gpu, PyTorch CUDA, PaddlePaddle GPU). The image auto-detects your NVIDIA GPU at runtime and falls back to CPU if none is found. + +Requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) on the host. Linux amd64 only. + +### Benchmarks + +Tested on an NVIDIA RTX 4070 (12 GB VRAM) with a 572x1024 JPEG portrait. Both images ran on the same machine. "Warm" means the model is already loaded in memory (second request onward). + +#### Warm performance + +| Tool | CPU | GPU | Speedup | +|------|-----|-----|---------| +| Background removal (u2net) | 2,415ms | 879ms | 2.7x | +| Background removal (isnet) | 2,457ms | 1,137ms | 2.2x | +| Upscale 2x | 350ms | 309ms | 1.1x | +| Upscale 4x | 910ms | 310ms | 2.9x | +| OCR (PaddleOCR) | 137ms | 94ms | 1.5x | +| Face blur | 139ms | 122ms | 1.1x | + +#### Cold start (first request after container start) + +| Tool | CPU | GPU | Speedup | +|------|-----|-----|---------| +| Background removal | 22,286ms | 4,792ms | 4.7x | +| Upscale 2x | 3,957ms | 2,318ms | 1.7x | +| OCR (PaddleOCR) | 1,469ms | 1,090ms | 1.3x | + +Cold start includes loading the model into memory. GPU cold starts are faster because CUDA parallelizes the model loading. + +Larger images show bigger speedups, especially for upscaling. Non-AI tools (resize, crop, convert, etc.) are unaffected since they use Sharp (CPU-based). + +### GPU health check + +After the first AI request, the admin health endpoint reports GPU status: + +``` +GET /api/v1/admin/health +{"ai": {"gpu": true}} +``` + ## Docker Compose ### Full @@ -71,6 +117,30 @@ volumes: stirling-workspace: ``` +### CUDA + +```yaml +services: + stirling-image: + image: stirlingimage/stirling-image:cuda + ports: + - "1349:1349" + volumes: + - stirling-data:/data + - stirling-workspace:/tmp/workspace + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + +volumes: + stirling-data: + stirling-workspace: +``` + ## Switching from lite to full To upgrade from lite to full and unlock AI tools: @@ -90,7 +160,10 @@ Both variants support semver tags for pinning: |-----|------------| | `latest` | Latest full release | | `lite` | Latest lite release | +| `cuda` | Latest full release with GPU support | | `1.6.0` | Exact full version | | `1.6.0-lite` | Exact lite version | +| `1.6.0-cuda` | Exact CUDA version | | `1.6` | Latest patch in 1.6.x (full) | | `1.6-lite` | Latest patch in 1.6.x (lite) | +| `1.6-cuda` | Latest patch in 1.6.x (CUDA) | diff --git a/apps/docs/guide/getting-started.md b/apps/docs/guide/getting-started.md index e370a4d1..c484d385 100644 --- a/apps/docs/guide/getting-started.md +++ b/apps/docs/guide/getting-started.md @@ -24,6 +24,16 @@ stirlingimage/stirling-image:lite All 27+ image processing tools work the same. See [Docker Tags](./docker-tags) for the full comparison. ::: +::: tip GPU acceleration +Have an NVIDIA GPU? The CUDA image auto-detects your GPU and accelerates background removal (2.7x), upscaling (3x), and OCR (1.5x): + +```bash +docker run -d --gpus all -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:cuda +``` + +Requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU if no GPU is found. See [Docker Tags](./docker-tags) for details and benchmarks. +::: + ## Run with Docker Compose Create a `docker-compose.yml`: diff --git a/apps/web/src/components/files/file-details.tsx b/apps/web/src/components/files/file-details.tsx index adebec69..ed8e9f66 100644 --- a/apps/web/src/components/files/file-details.tsx +++ b/apps/web/src/components/files/file-details.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { apiGetFileDetails, + formatHeaders, getFileDownloadUrl, getFileThumbnailUrl, type UserFileDetail, @@ -57,11 +58,10 @@ export function FileDetails({ mobile = false }: FileDetailsProps) { ? allFiles.filter((f) => checkedIds.has(f.id)) : [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }]; - const token = localStorage.getItem("stirling-token") || ""; const downloaded = await Promise.all( filesToOpen.map(async (f) => { const res = await fetch(getFileDownloadUrl(f.id), { - headers: { Authorization: `Bearer ${token}` }, + headers: formatHeaders(), }); if (!res.ok) return null; const blob = await res.blob(); diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index fab27dd4..dbfe4749 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -23,7 +23,7 @@ import { X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { apiDelete, apiGet, apiPost, apiPut, clearToken } from "@/lib/api"; +import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { cn, copyToClipboard } from "@/lib/utils"; import { GemLogo } from "../common/gem-logo"; @@ -287,10 +287,9 @@ function SystemSection() { const formData = new FormData(); formData.append("file", file); try { - const token = localStorage.getItem("stirling-token"); await fetch("/api/v1/settings/logo", { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: formatHeaders(), body: formData, }); setSettings((prev) => ({ ...prev, customLogo: "true" })); diff --git a/apps/web/src/components/tools/barcode-read-settings.tsx b/apps/web/src/components/tools/barcode-read-settings.tsx index 87853913..c4ef2efa 100644 --- a/apps/web/src/components/tools/barcode-read-settings.tsx +++ b/apps/web/src/components/tools/barcode-read-settings.tsx @@ -1,12 +1,8 @@ import { Check, Copy, Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { copyToClipboard } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function BarcodeReadSettings() { const { files, processing, error, setProcessing, setError } = useFileStore(); const [result, setResult] = useState<{ found: boolean; text: string | null } | null>(null); @@ -25,7 +21,7 @@ export function BarcodeReadSettings() { const res = await fetch("/api/v1/tools/barcode-read", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/bulk-rename-settings.tsx b/apps/web/src/components/tools/bulk-rename-settings.tsx index 6490c0d0..8a8878b8 100644 --- a/apps/web/src/components/tools/bulk-rename-settings.tsx +++ b/apps/web/src/components/tools/bulk-rename-settings.tsx @@ -1,11 +1,7 @@ import { Download, Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function BulkRenameSettings() { const { files, processing, error, setProcessing, setError } = useFileStore(); const [pattern, setPattern] = useState("image-{{index}}"); @@ -28,7 +24,7 @@ export function BulkRenameSettings() { const res = await fetch("/api/v1/tools/bulk-rename", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/collage-settings.tsx b/apps/web/src/components/tools/collage-settings.tsx index dc0ce283..6977a81d 100644 --- a/apps/web/src/components/tools/collage-settings.tsx +++ b/apps/web/src/components/tools/collage-settings.tsx @@ -1,11 +1,8 @@ import { Download, Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - type Layout = "2x2" | "3x3" | "1x3" | "2x1" | "3x1" | "1x2"; const LAYOUTS: { value: Layout; label: string }[] = [ @@ -43,7 +40,7 @@ export function CollageSettings() { const res = await fetch("/api/v1/tools/collage", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/color-palette-settings.tsx b/apps/web/src/components/tools/color-palette-settings.tsx index d6475ee7..f458a40a 100644 --- a/apps/web/src/components/tools/color-palette-settings.tsx +++ b/apps/web/src/components/tools/color-palette-settings.tsx @@ -1,12 +1,8 @@ import { Check, Copy, Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { copyToClipboard } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function ColorPaletteSettings() { const { files, processing, error, setProcessing, setError } = useFileStore(); const [colors, setColors] = useState([]); @@ -25,7 +21,7 @@ export function ColorPaletteSettings() { const res = await fetch("/api/v1/tools/color-palette", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/compare-settings.tsx b/apps/web/src/components/tools/compare-settings.tsx index 7ec4778f..2e611916 100644 --- a/apps/web/src/components/tools/compare-settings.tsx +++ b/apps/web/src/components/tools/compare-settings.tsx @@ -1,11 +1,7 @@ import { Download, Loader2, Upload } from "lucide-react"; import { useRef, useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function CompareSettings() { const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore(); const [secondFile, setSecondFile] = useState(null); @@ -28,7 +24,7 @@ export function CompareSettings() { const res = await fetch("/api/v1/tools/compare", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/compose-settings.tsx b/apps/web/src/components/tools/compose-settings.tsx index 56086d65..b6454c09 100644 --- a/apps/web/src/components/tools/compose-settings.tsx +++ b/apps/web/src/components/tools/compose-settings.tsx @@ -1,11 +1,7 @@ import { Download, Loader2, Upload } from "lucide-react"; import { useRef, useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function ComposeSettings() { const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore(); @@ -34,7 +30,7 @@ export function ComposeSettings() { const res = await fetch("/api/v1/tools/compose", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/erase-object-settings.tsx b/apps/web/src/components/tools/erase-object-settings.tsx index 1d70993f..0f9381d1 100644 --- a/apps/web/src/components/tools/erase-object-settings.tsx +++ b/apps/web/src/components/tools/erase-object-settings.tsx @@ -1,14 +1,11 @@ import { Download, Redo, Trash2 } from "lucide-react"; import { useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { formatHeaders } from "@/lib/api"; import { generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; import type { EraserCanvasRef } from "./eraser-canvas"; -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - interface EraseObjectSettingsProps { eraserRef: React.RefObject; hasStrokes: boolean; @@ -114,7 +111,9 @@ export function EraseObjectSettings({ setProgressPhase("idle"); }; xhr.open("POST", "/api/v1/tools/erase-object"); - xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`); + formatHeaders().forEach((value, key) => { + xhr.setRequestHeader(key, value); + }); xhr.send(formData); }; diff --git a/apps/web/src/components/tools/favicon-settings.tsx b/apps/web/src/components/tools/favicon-settings.tsx index befe97d6..f3328d8b 100644 --- a/apps/web/src/components/tools/favicon-settings.tsx +++ b/apps/web/src/components/tools/favicon-settings.tsx @@ -1,11 +1,8 @@ import { Download, Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - const SIZES = [ { name: "favicon-16x16.png", size: "16x16" }, { name: "favicon-32x32.png", size: "32x32" }, @@ -33,7 +30,7 @@ export function FaviconSettings() { const res = await fetch("/api/v1/tools/favicon", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/find-duplicates-settings.tsx b/apps/web/src/components/tools/find-duplicates-settings.tsx index c6594f67..2abfe7e9 100644 --- a/apps/web/src/components/tools/find-duplicates-settings.tsx +++ b/apps/web/src/components/tools/find-duplicates-settings.tsx @@ -1,11 +1,8 @@ import { Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - interface DuplicateGroup { files: Array<{ filename: string; similarity: number }>; } @@ -35,7 +32,7 @@ export function FindDuplicatesSettings() { const res = await fetch("/api/v1/tools/find-duplicates", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/image-to-pdf-settings.tsx b/apps/web/src/components/tools/image-to-pdf-settings.tsx index e13fe15e..611649c2 100644 --- a/apps/web/src/components/tools/image-to-pdf-settings.tsx +++ b/apps/web/src/components/tools/image-to-pdf-settings.tsx @@ -1,5 +1,6 @@ import { Download, Loader2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; const PAGE_SIZES: Record = { @@ -108,11 +109,6 @@ function PdfPagePreview({ ); } - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function ImageToPdfSettings() { const { files, selectedIndex, processing, error, setProcessing, setError } = useFileStore(); const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4"); @@ -136,7 +132,7 @@ export function ImageToPdfSettings() { const res = await fetch("/api/v1/tools/image-to-pdf", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/info-settings.tsx b/apps/web/src/components/tools/info-settings.tsx index 5d75c936..93e9a85b 100644 --- a/apps/web/src/components/tools/info-settings.tsx +++ b/apps/web/src/components/tools/info-settings.tsx @@ -1,11 +1,8 @@ import { Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - interface ImageInfoData { filename: string; fileSize: number; @@ -50,7 +47,7 @@ export function InfoSettings() { const res = await fetch("/api/v1/tools/info", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/ocr-settings.tsx b/apps/web/src/components/tools/ocr-settings.tsx index 09d9ab66..442f3eca 100644 --- a/apps/web/src/components/tools/ocr-settings.tsx +++ b/apps/web/src/components/tools/ocr-settings.tsx @@ -1,13 +1,10 @@ import { Check, Copy } from "lucide-react"; import { useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { formatHeaders } from "@/lib/api"; import { copyToClipboard, generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - type OcrEngine = "tesseract" | "paddleocr"; const LANGUAGES = [ @@ -112,7 +109,9 @@ export function OcrSettings() { setProgressPhase("idle"); }; xhr.open("POST", "/api/v1/tools/ocr"); - xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`); + formatHeaders().forEach((value, key) => { + xhr.setRequestHeader(key, value); + }); xhr.send(formData); }; diff --git a/apps/web/src/components/tools/qr-generate-settings.tsx b/apps/web/src/components/tools/qr-generate-settings.tsx index 8424af3f..4316e465 100644 --- a/apps/web/src/components/tools/qr-generate-settings.tsx +++ b/apps/web/src/components/tools/qr-generate-settings.tsx @@ -1,10 +1,6 @@ import { Download, Loader2 } from "lucide-react"; import { useState } from "react"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - +import { formatHeaders } from "@/lib/api"; export function QrGenerateSettings() { const [text, setText] = useState(""); const [size, setSize] = useState(400); @@ -27,10 +23,7 @@ export function QrGenerateSettings() { try { const res = await fetch("/api/v1/tools/qr-generate", { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${getToken()}`, - }, + headers: formatHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ text, size, errorCorrection, foreground, background }), }); diff --git a/apps/web/src/components/tools/split-settings.tsx b/apps/web/src/components/tools/split-settings.tsx index 44dbb5ca..3d809a75 100644 --- a/apps/web/src/components/tools/split-settings.tsx +++ b/apps/web/src/components/tools/split-settings.tsx @@ -1,11 +1,7 @@ import { Download, Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function SplitSettings() { const { files, processing, error, setProcessing, setError } = useFileStore(); const [columns, setColumns] = useState(2); @@ -26,7 +22,7 @@ export function SplitSettings() { const res = await fetch("/api/v1/tools/split", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx index 7f8a3090..aac6f3a0 100644 --- a/apps/web/src/components/tools/strip-metadata-settings.tsx +++ b/apps/web/src/components/tools/strip-metadata-settings.tsx @@ -2,12 +2,9 @@ import { AlertTriangle, ChevronDown, ChevronRight, Download, Loader2, MapPin } f import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - interface MetadataResult { filename: string; fileSize: number; @@ -342,7 +339,7 @@ export function StripMetadataSettings() { formData.append("file", currentFile); const res = await fetch("/api/v1/tools/strip-metadata/inspect", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, signal: controller.signal, }); diff --git a/apps/web/src/components/tools/svg-to-raster-settings.tsx b/apps/web/src/components/tools/svg-to-raster-settings.tsx index 6f86cb61..eed82050 100644 --- a/apps/web/src/components/tools/svg-to-raster-settings.tsx +++ b/apps/web/src/components/tools/svg-to-raster-settings.tsx @@ -1,11 +1,7 @@ import { Download, Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function SvgToRasterSettings() { const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore(); @@ -38,7 +34,7 @@ export function SvgToRasterSettings() { const res = await fetch("/api/v1/tools/svg-to-raster", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/vectorize-settings.tsx b/apps/web/src/components/tools/vectorize-settings.tsx index 696e525d..a86862c2 100644 --- a/apps/web/src/components/tools/vectorize-settings.tsx +++ b/apps/web/src/components/tools/vectorize-settings.tsx @@ -1,11 +1,7 @@ import { Download, Loader2 } from "lucide-react"; import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function VectorizeSettings() { const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore(); @@ -30,7 +26,7 @@ export function VectorizeSettings() { const res = await fetch("/api/v1/tools/vectorize", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/components/tools/watermark-image-settings.tsx b/apps/web/src/components/tools/watermark-image-settings.tsx index 1f05bc65..db3dae12 100644 --- a/apps/web/src/components/tools/watermark-image-settings.tsx +++ b/apps/web/src/components/tools/watermark-image-settings.tsx @@ -1,13 +1,9 @@ import { Download, Loader2, Upload } from "lucide-react"; import { useRef, useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function WatermarkImageSettings() { const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore(); @@ -35,7 +31,7 @@ export function WatermarkImageSettings() { const res = await fetch("/api/v1/tools/watermark-image", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index f9aa3764..efd166b0 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { formatHeaders } from "@/lib/api"; interface AuthState { loading: boolean; @@ -45,7 +46,7 @@ export function useAuth(): AuthState { } const sessionRes = await fetch("/api/auth/session", { - headers: { Authorization: `Bearer ${token}` }, + headers: formatHeaders(), }); if (sessionRes.ok) { diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index e0edd59c..7311ade8 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -1,12 +1,9 @@ import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared"; import { useCallback, useEffect, useRef, useState } from "react"; +import { formatHeaders } from "@/lib/api"; import { generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - interface ProcessResult { jobId: string; downloadUrl: string; @@ -222,10 +219,9 @@ export function useToolProcessor(toolId: string) { }; xhr.open("POST", `/api/v1/tools/${toolId}`); - const token = getToken(); - if (token) { - xhr.setRequestHeader("Authorization", `Bearer ${token}`); - } + formatHeaders().forEach((value, key) => { + xhr.setRequestHeader(key, value); + }); xhr.send(formData); }, [toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId], @@ -292,10 +288,9 @@ export function useToolProcessor(toolId: string) { formData.append("clientJobId", clientJobId); try { - const token = getToken(); const response = await fetch(`/api/v1/tools/${toolId}/batch`, { method: "POST", - headers: token ? { Authorization: `Bearer ${token}` } : {}, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 2208a195..089a928d 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,5 +1,26 @@ const API_BASE = "/api"; +// ── Auth Headers ─────────────────────────────────────────────── + +function getToken(): string { + try { + return localStorage.getItem("stirling-token") || ""; + } catch { + return ""; + } +} + +// Skip Authorization header when no token exists. +// An empty Bearer token breaks forward-auth proxies (e.g. Authelia). +export function formatHeaders(init?: HeadersInit): Headers { + const headers = new Headers(init); + const token = getToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + return headers; +} + async function throwWithMessage(res: Response): Promise { let msg = `API error: ${res.status}`; try { @@ -14,7 +35,7 @@ async function throwWithMessage(res: Response): Promise { export async function apiGet(path: string): Promise { const res = await fetch(`${API_BASE}${path}`, { - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), }); if (!res.ok) await throwWithMessage(res); return res.json(); @@ -23,10 +44,7 @@ export async function apiGet(path: string): Promise { export async function apiPost(path: string, body?: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${getToken()}`, - }, + headers: formatHeaders({ "Content-Type": "application/json" }), body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) await throwWithMessage(res); @@ -36,10 +54,7 @@ export async function apiPost(path: string, body?: unknown): Promise { export async function apiPut(path: string, body?: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${getToken()}`, - }, + headers: formatHeaders({ "Content-Type": "application/json" }), body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) await throwWithMessage(res); @@ -49,18 +64,12 @@ export async function apiPut(path: string, body?: unknown): Promise { export async function apiDelete(path: string): Promise { const res = await fetch(`${API_BASE}${path}`, { method: "DELETE", - headers: { - Authorization: `Bearer ${getToken()}`, - }, + headers: formatHeaders(), }); if (!res.ok) await throwWithMessage(res); return res.json(); } -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function setToken(token: string) { localStorage.setItem("stirling-token", token); } @@ -79,7 +88,7 @@ export async function apiUpload(files: File[]): Promise<{ for (const f of files) formData.append("files", f); const res = await fetch("/api/v1/upload", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); if (!res.ok) throw new Error(`Upload failed: ${res.status}`); @@ -141,7 +150,7 @@ export async function apiUploadUserFiles( for (const f of files) formData.append("files", f); const res = await fetch("/api/v1/files/upload", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); if (!res.ok) throw new Error(`Upload failed: ${res.status}`); @@ -151,10 +160,7 @@ export async function apiUploadUserFiles( export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> { const res = await fetch("/api/v1/files", { method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${getToken()}`, - }, + headers: formatHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ ids }), }); if (!res.ok) throw new Error(`Delete failed: ${res.status}`); @@ -171,7 +177,7 @@ export function getFileDownloadUrl(id: string): string { export async function apiDownloadBlob(jobId: string, filename: string): Promise { const res = await fetch(getDownloadUrl(jobId, filename), { - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), }); if (!res.ok) throw new Error(`Download failed: ${res.status}`); return res.blob(); diff --git a/apps/web/src/pages/automate-page.tsx b/apps/web/src/pages/automate-page.tsx index c1be7b7e..2abf30e8 100644 --- a/apps/web/src/pages/automate-page.tsx +++ b/apps/web/src/pages/automate-page.tsx @@ -2,6 +2,7 @@ import { Play, Trash2, Workflow } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { AppLayout } from "@/components/layout/app-layout"; import { PipelineBuilder, type PipelineStep } from "@/components/tools/pipeline-builder"; +import { formatHeaders } from "@/lib/api"; import { generateId } from "@/lib/utils"; interface SavedPipeline { @@ -11,11 +12,6 @@ interface SavedPipeline { steps: Array<{ toolId: string; settings: Record }>; createdAt: string; } - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - export function AutomatePage() { const [steps, setSteps] = useState([]); const [savedPipelines, setSavedPipelines] = useState([]); @@ -33,7 +29,7 @@ export function AutomatePage() { const loadPipelines = useCallback(async () => { try { const res = await fetch("/api/v1/pipeline/list", { - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), }); if (res.ok) { const data = await res.json(); @@ -55,10 +51,7 @@ export function AutomatePage() { try { const res = await fetch("/api/v1/pipeline/save", { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${getToken()}`, - }, + headers: formatHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ name, description: description || undefined, @@ -86,7 +79,7 @@ export function AutomatePage() { try { await fetch(`/api/v1/pipeline/${id}`, { method: "DELETE", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), }); await loadPipelines(); } catch { @@ -117,7 +110,7 @@ export function AutomatePage() { const res = await fetch("/api/v1/pipeline/execute", { method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, + headers: formatHeaders(), body: formData, }); diff --git a/apps/web/src/pages/change-password-page.tsx b/apps/web/src/pages/change-password-page.tsx index 96fb9f53..b71774ab 100644 --- a/apps/web/src/pages/change-password-page.tsx +++ b/apps/web/src/pages/change-password-page.tsx @@ -1,4 +1,5 @@ import { type FormEvent, useRef, useState } from "react"; +import { formatHeaders } from "@/lib/api"; /** * Trigger the browser's "Save Password" prompt by submitting a real form @@ -85,13 +86,9 @@ export function ChangePasswordPage() { setLoading(true); try { - const token = localStorage.getItem("stirling-token"); const res = await fetch("/api/auth/change-password", { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + headers: formatHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ currentPassword, newPassword }), }); diff --git a/docker/Dockerfile b/docker/Dockerfile index 474880e5..3125602f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -5,6 +5,7 @@ # ============================================ ARG VARIANT=full +ARG GPU=false # ============================================ # Stage 1: Build the frontend (Vite + React) @@ -38,11 +39,35 @@ RUN --mount=type=cache,id=turbo-cache,target=/app/.turbo \ pnpm --filter @stirling-image/web build # ============================================ -# Stage 2: Production runtime +# Stage 2: Base image selection # ============================================ -FROM node:22-bookworm AS production +FROM node:22-bookworm AS base-cpu +FROM nvidia/cuda:12.6.3-runtime-ubuntu24.04 AS base-gpu + +# Select base: GPU=false -> base-cpu, GPU=true -> base-gpu +FROM base-cpu AS production-base-false +FROM base-gpu AS production-base-true + +# ============================================ +# Stage 3: Production runtime +# ============================================ +FROM production-base-${GPU} AS production ARG VARIANT +ARG GPU + +# Install Node.js when using CUDA base (node:22-bookworm already has it) +RUN if [ "$GPU" = "true" ]; then \ + apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates gnupg && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | \ + gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > \ + /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* \ +; fi RUN corepack enable && corepack prepare pnpm@9.15.4 --activate @@ -68,17 +93,30 @@ RUN if [ "$VARIANT" = "full" ]; then \ # Python venv + ML packages + model weights (full variant only) COPY packages/ai/python/requirements.txt /tmp/requirements.txt +COPY packages/ai/python/requirements-gpu.txt /tmp/requirements-gpu.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 + if [ "$GPU" = "true" ]; then \ + /opt/venv/bin/pip install \ + Pillow numpy opencv-python-headless onnxruntime-gpu && \ + (/opt/venv/bin/pip install rembg || echo "WARNING: rembg not installed") && \ + (/opt/venv/bin/pip install realesrgan \ + --extra-index-url https://download.pytorch.org/whl/cu126 \ + || echo "WARNING: realesrgan not installed") && \ + (/opt/venv/bin/pip install paddlepaddle-gpu 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") \ + ; else \ + /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 \ +; fi && rm -f /tmp/requirements.txt /tmp/requirements-gpu.txt COPY docker/download_models.py /tmp/download_models.py RUN if [ "$VARIANT" = "full" ]; then \ @@ -152,7 +190,8 @@ ENV PORT=1349 \ CONCURRENT_JOBS=3 \ MAX_MEGAPIXELS=100 \ RATE_LIMIT_PER_MIN=100 \ - STIRLING_VARIANT=${VARIANT} + STIRLING_VARIANT=${VARIANT} \ + STIRLING_GPU=${GPU} # Create non-root user for runtime RUN groupadd -r stirling && useradd -r -g stirling -d /app -s /sbin/nologin stirling diff --git a/docker/docker-compose.gpu.yml b/docker/docker-compose.gpu.yml new file mode 100644 index 00000000..86f5bdc0 --- /dev/null +++ b/docker/docker-compose.gpu.yml @@ -0,0 +1,17 @@ +# GPU override - use with: +# docker compose -f docker/docker-compose.yml -f docker/docker-compose.gpu.yml up +services: + stirling-image: + build: + context: .. + dockerfile: docker/Dockerfile + args: + GPU: "true" + image: stirlingimage/stirling-image:cuda + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] diff --git a/packages/ai/python/dispatcher.py b/packages/ai/python/dispatcher.py index ce6d3861..c0030a07 100644 --- a/packages/ai/python/dispatcher.py +++ b/packages/ai/python/dispatcher.py @@ -39,6 +39,7 @@ def _try_import(name, import_fn): _try_import("PIL", lambda: __import__("PIL")) _try_import("cv2", lambda: __import__("cv2")) _try_import("numpy", lambda: __import__("numpy")) +_try_import("gpu", lambda: __import__("gpu")) # Heavy ML libraries - import but don't fail if unavailable _try_import("rembg", lambda: __import__("rembg")) @@ -123,8 +124,14 @@ def _run_script_main(script_name, args): def main(): - # Signal readiness - print(json.dumps({"ready": True}), file=sys.stderr, flush=True) + # Signal readiness with GPU status + gpu = False + try: + from gpu import gpu_available + gpu = gpu_available() + except ImportError: + pass + print(json.dumps({"ready": True, "gpu": gpu}), file=sys.stderr, flush=True) for line in sys.stdin: line = line.strip() diff --git a/packages/ai/python/gpu.py b/packages/ai/python/gpu.py new file mode 100644 index 00000000..60031e88 --- /dev/null +++ b/packages/ai/python/gpu.py @@ -0,0 +1,34 @@ +"""Runtime GPU/CUDA detection utility.""" +import functools +import os + + +@functools.lru_cache(maxsize=1) +def gpu_available(): + """Return True if a usable CUDA GPU is present at runtime.""" + override = os.environ.get("STIRLING_GPU") + if override is not None: + return override.lower() in ("1", "true", "yes") + + try: + import onnxruntime + if "CUDAExecutionProvider" in onnxruntime.get_available_providers(): + return True + except ImportError: + pass + + try: + import torch + if torch.cuda.is_available(): + return True + except ImportError: + pass + + return False + + +def onnx_providers(): + """Return ONNX Runtime execution providers in priority order.""" + if gpu_available(): + return ["CUDAExecutionProvider", "CPUExecutionProvider"] + return ["CPUExecutionProvider"] diff --git a/packages/ai/python/ocr.py b/packages/ai/python/ocr.py index ad089cd1..799aecf6 100644 --- a/packages/ai/python/ocr.py +++ b/packages/ai/python/ocr.py @@ -34,9 +34,10 @@ def run_paddleocr(input_path, language): """Run PaddleOCR.""" os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" from paddleocr import PaddleOCR + from gpu import gpu_available emit_progress(20, "Loading") - ocr = PaddleOCR(lang=language) + ocr = PaddleOCR(lang=language, use_gpu=gpu_available()) emit_progress(30, "Scanning") result = ocr.ocr(input_path) emit_progress(70, "Extracting text") diff --git a/packages/ai/python/remove_bg.py b/packages/ai/python/remove_bg.py index 2119738b..84cab01d 100644 --- a/packages/ai/python/remove_bg.py +++ b/packages/ai/python/remove_bg.py @@ -24,11 +24,12 @@ def main(): try: from rembg import remove, new_session + from gpu import onnx_providers import io emit_progress(10, "Loading model") - session = new_session(model) + session = new_session(model, providers=onnx_providers()) emit_progress(25, "Model loaded") diff --git a/packages/ai/python/requirements-gpu.txt b/packages/ai/python/requirements-gpu.txt new file mode 100644 index 00000000..206ab325 --- /dev/null +++ b/packages/ai/python/requirements-gpu.txt @@ -0,0 +1,10 @@ +rembg==2.0.62 +realesrgan==0.3.0 +lama-cleaner==1.2.5 +paddleocr==2.9.1 +paddlepaddle-gpu==3.0.0 +mediapipe==0.10.21 +onnxruntime-gpu==1.20.1 +numpy==1.26.4 +Pillow==11.1.0 +opencv-python-headless==4.10.0.84 diff --git a/packages/ai/python/upscale.py b/packages/ai/python/upscale.py index dc122ef0..6560c99e 100644 --- a/packages/ai/python/upscale.py +++ b/packages/ai/python/upscale.py @@ -26,7 +26,12 @@ def main(): try: from basicsr.archs.rrdbnet_arch import RRDBNet from realesrgan import RealESRGANer + from gpu import gpu_available import numpy as np + import torch + + use_gpu = gpu_available() + device = torch.device("cuda" if use_gpu else "cpu") model = RRDBNet( num_in_ch=3, @@ -40,7 +45,8 @@ def main(): scale=scale, model_path=None, model=model, - half=False, + half=use_gpu, + device=device, ) emit_progress(20, "Model ready") img_array = np.array(img.convert("RGB")) diff --git a/packages/ai/src/bridge.ts b/packages/ai/src/bridge.ts index a08ec503..c989877a 100644 --- a/packages/ai/src/bridge.ts +++ b/packages/ai/src/bridge.ts @@ -54,6 +54,8 @@ interface PendingRequest { let dispatcher: ChildProcess | null = null; let dispatcherReady = false; let dispatcherFailed = false; +// biome-ignore lint/style/useConst: reassigned on dispatcher readiness signal +let dispatcherGpuAvailable = false; const pendingRequests = new Map(); let stdoutBuffer = ""; @@ -82,6 +84,7 @@ function startDispatcher(): ChildProcess | null { // Readiness signal if (parsed.ready === true) { dispatcherReady = true; + dispatcherGpuAvailable = parsed.gpu === true; continue; } @@ -221,6 +224,13 @@ function dispatcherRun( }); } +/** + * Whether the Python dispatcher detected a CUDA GPU at startup. + */ +export function isGpuAvailable(): boolean { + return dispatcherGpuAvailable; +} + /** * Shut down the persistent dispatcher process. */ diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 6bfbd601..cbd072d8 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,5 +1,5 @@ export { removeBackground } from "./background-removal.js"; -export { shutdownDispatcher } from "./bridge.js"; +export { isGpuAvailable, shutdownDispatcher } from "./bridge.js"; export { blurFaces } from "./face-detection.js"; export { inpaint } from "./inpainting.js"; export { extractText } from "./ocr.js"; diff --git a/tests/unit/web/stores.test.ts b/tests/unit/web/stores.test.ts index 327a45ca..00464c86 100644 --- a/tests/unit/web/stores.test.ts +++ b/tests/unit/web/stores.test.ts @@ -525,18 +525,18 @@ describe("API lib", () => { const result = await apiGet<{ data: string }>("/v1/health"); - expect(fetchMock).toHaveBeenCalledWith("/api/v1/health", { - headers: { Authorization: "Bearer tok-123" }, - }); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/health"); + expect(opts.headers.get("Authorization")).toBe("Bearer tok-123"); expect(result).toEqual({ data: "ok" }); }); - it("sends empty Bearer when no token is set", async () => { + it("omits Authorization header when no token is set", async () => { fetchMock.mockReturnValueOnce(okJson({})); await apiGet("/v1/anything"); const callArgs = fetchMock.mock.calls[0]; - expect(callArgs[1].headers.Authorization).toBe("Bearer "); + expect(callArgs[1].headers.get("Authorization")).toBeNull(); }); it("throws on non-ok response (e.g., 401)", async () => { @@ -567,8 +567,8 @@ describe("API lib", () => { const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/items"); expect(opts.method).toBe("POST"); - expect(opts.headers["Content-Type"]).toBe("application/json"); - expect(opts.headers.Authorization).toBe("Bearer post-tok"); + expect(opts.headers.get("Content-Type")).toBe("application/json"); + expect(opts.headers.get("Authorization")).toBe("Bearer post-tok"); expect(opts.body).toBe(JSON.stringify({ name: "test" })); expect(result).toEqual({ id: 1 }); }); @@ -599,8 +599,8 @@ describe("API lib", () => { const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/items/1"); expect(opts.method).toBe("PUT"); - expect(opts.headers["Content-Type"]).toBe("application/json"); - expect(opts.headers.Authorization).toBe("Bearer put-tok"); + expect(opts.headers.get("Content-Type")).toBe("application/json"); + expect(opts.headers.get("Authorization")).toBe("Bearer put-tok"); expect(opts.body).toBe(JSON.stringify({ name: "updated" })); expect(result).toEqual({ updated: true }); }); @@ -623,7 +623,7 @@ describe("API lib", () => { const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/items/1"); expect(opts.method).toBe("DELETE"); - expect(opts.headers.Authorization).toBe("Bearer del-tok"); + expect(opts.headers.get("Authorization")).toBe("Bearer del-tok"); expect(opts.body).toBeUndefined(); expect(result).toEqual({ deleted: true }); }); @@ -651,7 +651,7 @@ describe("API lib", () => { const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/upload"); expect(opts.method).toBe("POST"); - expect(opts.headers.Authorization).toBe("Bearer up-tok"); + expect(opts.headers.get("Authorization")).toBe("Bearer up-tok"); // Body should be FormData expect(opts.body).toBeInstanceOf(FormData); const fd = opts.body as FormData; @@ -673,7 +673,7 @@ describe("API lib", () => { await apiUpload([makeFile("x.png")]); const headers = fetchMock.mock.calls[0][1].headers; - expect(headers["Content-Type"]).toBeUndefined(); + expect(headers.get("Content-Type")).toBeNull(); }); it("throws on non-ok response with status in message", async () => { @@ -708,7 +708,7 @@ describe("API lib", () => { const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/download/job-1/result.png"); - expect(opts.headers.Authorization).toBe("Bearer dl-tok"); + expect(opts.headers.get("Authorization")).toBe("Bearer dl-tok"); expect(result).toBe(blob); }); @@ -748,20 +748,20 @@ describe("API lib", () => { setToken("first-token"); fetchMock.mockReturnValueOnce(okJson({})); await apiGet("/v1/a"); - expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer first-token"); + expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBe("Bearer first-token"); setToken("second-token"); fetchMock.mockReturnValueOnce(okJson({})); await apiGet("/v1/b"); - expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe("Bearer second-token"); + expect(fetchMock.mock.calls[1][1].headers.get("Authorization")).toBe("Bearer second-token"); }); - it("uses empty Bearer immediately after clearToken", async () => { + it("omits Authorization header after clearToken", async () => { setToken("about-to-die"); clearToken(); fetchMock.mockReturnValueOnce(okJson({})); await apiGet("/v1/c"); - expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer "); + expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBeNull(); }); }); });