Merge pull request #12 from stirling-image/feat/gpu-cuda-support

feat: GPU/CUDA acceleration (:cuda Docker tag)
This commit is contained in:
stirling-image
2026-04-05 20:36:12 +08:00
committed by GitHub
44 changed files with 383 additions and 213 deletions
+17 -6
View File
@@ -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 }}
+21 -9
View File
@@ -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 }}
+7 -1
View File
@@ -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
+2 -1
View File
@@ -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() },
};
});
+4
View File
@@ -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.
+2 -1
View File
@@ -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.
+74 -1
View File
@@ -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) |
+10
View File
@@ -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`:
@@ -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();
@@ -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" }));
@@ -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,
});
@@ -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,
});
@@ -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,
});
@@ -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<string[]>([]);
@@ -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,
});
@@ -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<File | null>(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,
});
@@ -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,
});
@@ -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<EraserCanvasRef | null>;
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);
};
@@ -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,
});
@@ -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,
});
@@ -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<string, [number, number]> = {
@@ -108,11 +109,6 @@ function PdfPagePreview({
</div>
);
}
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,
});
@@ -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,
});
@@ -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);
};
@@ -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 }),
});
@@ -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,
});
@@ -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,
});
@@ -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,
});
@@ -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,
});
@@ -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,
});
+2 -1
View File
@@ -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) {
+5 -10
View File
@@ -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,
});
+29 -23
View File
@@ -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<never> {
let msg = `API error: ${res.status}`;
try {
@@ -14,7 +35,7 @@ async function throwWithMessage(res: Response): Promise<never> {
export async function apiGet<T>(path: string): Promise<T> {
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<T>(path: string): Promise<T> {
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
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<T>(path: string, body?: unknown): Promise<T> {
export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
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<T>(path: string, body?: unknown): Promise<T> {
export async function apiDelete<T>(path: string): Promise<T> {
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<Blob> {
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();
+5 -12
View File
@@ -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<string, unknown> }>;
createdAt: string;
}
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function AutomatePage() {
const [steps, setSteps] = useState<PipelineStep[]>([]);
const [savedPipelines, setSavedPipelines] = useState<SavedPipeline[]>([]);
@@ -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,
});
+2 -5
View File
@@ -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 }),
});
+50 -11
View File
@@ -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
+17
View File
@@ -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]
+9 -2
View File
@@ -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()
+34
View File
@@ -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"]
+2 -1
View File
@@ -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")
+2 -1
View File
@@ -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")
+10
View File
@@ -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
+7 -1
View File
@@ -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"))
+10
View File
@@ -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<string, PendingRequest>();
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.
*/
+1 -1
View File
@@ -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";
+17 -17
View File
@@ -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();
});
});
});