mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #12 from stirling-image/feat/gpu-cuda-support
feat: GPU/CUDA acceleration (:cuda Docker tag)
This commit is contained in:
@@ -80,11 +80,20 @@ jobs:
|
|||||||
- run: pnpm build
|
- run: pnpm build
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Docker Build Test (${{ matrix.variant }})
|
name: Docker Build Test (${{ matrix.tag }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
variant: [full, lite]
|
include:
|
||||||
|
- tag: full
|
||||||
|
variant: full
|
||||||
|
gpu: "false"
|
||||||
|
- tag: lite
|
||||||
|
variant: lite
|
||||||
|
gpu: "false"
|
||||||
|
- tag: cuda
|
||||||
|
variant: full
|
||||||
|
gpu: "true"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -95,7 +104,9 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile
|
file: docker/Dockerfile
|
||||||
push: false
|
push: false
|
||||||
build-args: VARIANT=${{ matrix.variant }}
|
build-args: |
|
||||||
tags: stirling-image:ci-${{ matrix.variant }}
|
VARIANT=${{ matrix.variant }}
|
||||||
cache-from: type=gha,scope=${{ matrix.variant }}
|
GPU=${{ matrix.gpu }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
|
tags: stirling-image:ci-${{ matrix.tag }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.tag }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.tag }}
|
||||||
|
|||||||
@@ -49,18 +49,28 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Docker (${{ matrix.variant }})
|
name: Docker (${{ matrix.tag }})
|
||||||
needs: release
|
needs: release
|
||||||
if: needs.release.outputs.new_version != ''
|
if: needs.release.outputs.new_version != ''
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
variant: [full, lite]
|
|
||||||
include:
|
include:
|
||||||
- variant: full
|
- tag: full
|
||||||
|
variant: full
|
||||||
|
gpu: "false"
|
||||||
suffix: ""
|
suffix: ""
|
||||||
- variant: lite
|
platforms: "linux/amd64,linux/arm64"
|
||||||
|
- tag: lite
|
||||||
|
variant: lite
|
||||||
|
gpu: "false"
|
||||||
suffix: "-lite"
|
suffix: "-lite"
|
||||||
|
platforms: "linux/amd64,linux/arm64"
|
||||||
|
- tag: cuda
|
||||||
|
variant: full
|
||||||
|
gpu: "true"
|
||||||
|
suffix: "-cuda"
|
||||||
|
platforms: "linux/amd64"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout release tag
|
- name: Checkout release tag
|
||||||
uses: actions/checkout@v4
|
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={{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}}.{{minor}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }}
|
||||||
type=semver,pattern={{major}}${{ 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
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -105,9 +115,11 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile
|
file: docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
build-args: VARIANT=${{ matrix.variant }}
|
build-args: |
|
||||||
platforms: linux/amd64,linux/arm64
|
VARIANT=${{ matrix.variant }}
|
||||||
|
GPU=${{ matrix.gpu }}
|
||||||
|
platforms: ${{ matrix.platforms }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha,scope=${{ matrix.variant }}
|
cache-from: type=gha,scope=${{ matrix.tag }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.tag }}
|
||||||
|
|||||||
@@ -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
|
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.
|
Open http://localhost:1349 in your browser.
|
||||||
|
|
||||||
**Default credentials:**
|
**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.
|
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
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import { isGpuAvailable } from "@stirling-image/ai";
|
||||||
import { APP_VERSION } from "@stirling-image/shared";
|
import { APP_VERSION } from "@stirling-image/shared";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { env } from "./config.js";
|
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" },
|
storage: { mode: env.STORAGE_MODE, available: "N/A" },
|
||||||
database: dbOk ? "ok" : "error",
|
database: dbOk ? "ok" : "error",
|
||||||
queue: { active: 0, pending: 0 },
|
queue: { active: 0, pending: 0 },
|
||||||
ai: {},
|
ai: { gpu: isGpuAvailable() },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Background removal
|
||||||
|
|
||||||
Removes the background from an image and returns a transparent PNG.
|
Removes the background from an image and returns a transparent PNG.
|
||||||
|
|||||||
@@ -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.
|
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 |
|
| Variant | Tag | Size | What's included |
|
||||||
|---------|-----|------|-----------------|
|
|---------|-----|------|-----------------|
|
||||||
| Full | `:latest` | ~11 GB | All tools + AI/ML (background removal, upscaling, OCR, face blur, object eraser) |
|
| 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 |
|
| 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.
|
See [Docker Tags](./docker-tags) for the full comparison, Docker Compose examples, and version pinning.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Docker Image Tags
|
# 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)
|
## Full (default)
|
||||||
|
|
||||||
@@ -35,6 +35,52 @@ Use this if you:
|
|||||||
|
|
||||||
All other tools (27+) work identically in both variants.
|
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
|
## Docker Compose
|
||||||
|
|
||||||
### Full
|
### Full
|
||||||
@@ -71,6 +117,30 @@ volumes:
|
|||||||
stirling-workspace:
|
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
|
## Switching from lite to full
|
||||||
|
|
||||||
To upgrade from lite to full and unlock AI tools:
|
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 |
|
| `latest` | Latest full release |
|
||||||
| `lite` | Latest lite release |
|
| `lite` | Latest lite release |
|
||||||
|
| `cuda` | Latest full release with GPU support |
|
||||||
| `1.6.0` | Exact full version |
|
| `1.6.0` | Exact full version |
|
||||||
| `1.6.0-lite` | Exact lite 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` | Latest patch in 1.6.x (full) |
|
||||||
| `1.6-lite` | Latest patch in 1.6.x (lite) |
|
| `1.6-lite` | Latest patch in 1.6.x (lite) |
|
||||||
|
| `1.6-cuda` | Latest patch in 1.6.x (CUDA) |
|
||||||
|
|||||||
@@ -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.
|
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
|
## Run with Docker Compose
|
||||||
|
|
||||||
Create a `docker-compose.yml`:
|
Create a `docker-compose.yml`:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
apiGetFileDetails,
|
apiGetFileDetails,
|
||||||
|
formatHeaders,
|
||||||
getFileDownloadUrl,
|
getFileDownloadUrl,
|
||||||
getFileThumbnailUrl,
|
getFileThumbnailUrl,
|
||||||
type UserFileDetail,
|
type UserFileDetail,
|
||||||
@@ -57,11 +58,10 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
|||||||
? allFiles.filter((f) => checkedIds.has(f.id))
|
? allFiles.filter((f) => checkedIds.has(f.id))
|
||||||
: [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }];
|
: [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }];
|
||||||
|
|
||||||
const token = localStorage.getItem("stirling-token") || "";
|
|
||||||
const downloaded = await Promise.all(
|
const downloaded = await Promise.all(
|
||||||
filesToOpen.map(async (f) => {
|
filesToOpen.map(async (f) => {
|
||||||
const res = await fetch(getFileDownloadUrl(f.id), {
|
const res = await fetch(getFileDownloadUrl(f.id), {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "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 { cn, copyToClipboard } from "@/lib/utils";
|
||||||
import { GemLogo } from "../common/gem-logo";
|
import { GemLogo } from "../common/gem-logo";
|
||||||
|
|
||||||
@@ -287,10 +287,9 @@ function SystemSection() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("stirling-token");
|
|
||||||
await fetch("/api/v1/settings/logo", {
|
await fetch("/api/v1/settings/logo", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
setSettings((prev) => ({ ...prev, customLogo: "true" }));
|
setSettings((prev) => ({ ...prev, customLogo: "true" }));
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { copyToClipboard } from "@/lib/utils";
|
import { copyToClipboard } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BarcodeReadSettings() {
|
export function BarcodeReadSettings() {
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
const [result, setResult] = useState<{ found: boolean; text: string | null } | null>(null);
|
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", {
|
const res = await fetch("/api/v1/tools/barcode-read", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BulkRenameSettings() {
|
export function BulkRenameSettings() {
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
const [pattern, setPattern] = useState("image-{{index}}");
|
const [pattern, setPattern] = useState("image-{{index}}");
|
||||||
@@ -28,7 +24,7 @@ export function BulkRenameSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/bulk-rename", {
|
const res = await fetch("/api/v1/tools/bulk-rename", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
type Layout = "2x2" | "3x3" | "1x3" | "2x1" | "3x1" | "1x2";
|
type Layout = "2x2" | "3x3" | "1x3" | "2x1" | "3x1" | "1x2";
|
||||||
|
|
||||||
const LAYOUTS: { value: Layout; label: string }[] = [
|
const LAYOUTS: { value: Layout; label: string }[] = [
|
||||||
@@ -43,7 +40,7 @@ export function CollageSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/collage", {
|
const res = await fetch("/api/v1/tools/collage", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { copyToClipboard } from "@/lib/utils";
|
import { copyToClipboard } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ColorPaletteSettings() {
|
export function ColorPaletteSettings() {
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
const [colors, setColors] = useState<string[]>([]);
|
const [colors, setColors] = useState<string[]>([]);
|
||||||
@@ -25,7 +21,7 @@ export function ColorPaletteSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/color-palette", {
|
const res = await fetch("/api/v1/tools/color-palette", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CompareSettings() {
|
export function CompareSettings() {
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore();
|
const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore();
|
||||||
const [secondFile, setSecondFile] = useState<File | null>(null);
|
const [secondFile, setSecondFile] = useState<File | null>(null);
|
||||||
@@ -28,7 +24,7 @@ export function CompareSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/compare", {
|
const res = await fetch("/api/v1/tools/compare", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComposeSettings() {
|
export function ComposeSettings() {
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
@@ -34,7 +30,7 @@ export function ComposeSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/compose", {
|
const res = await fetch("/api/v1/tools/compose", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { Download, Redo, Trash2 } from "lucide-react";
|
import { Download, Redo, Trash2 } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import type { EraserCanvasRef } from "./eraser-canvas";
|
import type { EraserCanvasRef } from "./eraser-canvas";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EraseObjectSettingsProps {
|
interface EraseObjectSettingsProps {
|
||||||
eraserRef: React.RefObject<EraserCanvasRef | null>;
|
eraserRef: React.RefObject<EraserCanvasRef | null>;
|
||||||
hasStrokes: boolean;
|
hasStrokes: boolean;
|
||||||
@@ -114,7 +111,9 @@ export function EraseObjectSettings({
|
|||||||
setProgressPhase("idle");
|
setProgressPhase("idle");
|
||||||
};
|
};
|
||||||
xhr.open("POST", "/api/v1/tools/erase-object");
|
xhr.open("POST", "/api/v1/tools/erase-object");
|
||||||
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
|
formatHeaders().forEach((value, key) => {
|
||||||
|
xhr.setRequestHeader(key, value);
|
||||||
|
});
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIZES = [
|
const SIZES = [
|
||||||
{ name: "favicon-16x16.png", size: "16x16" },
|
{ name: "favicon-16x16.png", size: "16x16" },
|
||||||
{ name: "favicon-32x32.png", size: "32x32" },
|
{ name: "favicon-32x32.png", size: "32x32" },
|
||||||
@@ -33,7 +30,7 @@ export function FaviconSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/favicon", {
|
const res = await fetch("/api/v1/tools/favicon", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DuplicateGroup {
|
interface DuplicateGroup {
|
||||||
files: Array<{ filename: string; similarity: number }>;
|
files: Array<{ filename: string; similarity: number }>;
|
||||||
}
|
}
|
||||||
@@ -35,7 +32,7 @@ export function FindDuplicatesSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/find-duplicates", {
|
const res = await fetch("/api/v1/tools/find-duplicates", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const PAGE_SIZES: Record<string, [number, number]> = {
|
const PAGE_SIZES: Record<string, [number, number]> = {
|
||||||
@@ -108,11 +109,6 @@ function PdfPagePreview({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImageToPdfSettings() {
|
export function ImageToPdfSettings() {
|
||||||
const { files, selectedIndex, processing, error, setProcessing, setError } = useFileStore();
|
const { files, selectedIndex, processing, error, setProcessing, setError } = useFileStore();
|
||||||
const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4");
|
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", {
|
const res = await fetch("/api/v1/tools/image-to-pdf", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageInfoData {
|
interface ImageInfoData {
|
||||||
filename: string;
|
filename: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
@@ -50,7 +47,7 @@ export function InfoSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/info", {
|
const res = await fetch("/api/v1/tools/info", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { copyToClipboard, generateId } from "@/lib/utils";
|
import { copyToClipboard, generateId } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
type OcrEngine = "tesseract" | "paddleocr";
|
type OcrEngine = "tesseract" | "paddleocr";
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
@@ -112,7 +109,9 @@ export function OcrSettings() {
|
|||||||
setProgressPhase("idle");
|
setProgressPhase("idle");
|
||||||
};
|
};
|
||||||
xhr.open("POST", "/api/v1/tools/ocr");
|
xhr.open("POST", "/api/v1/tools/ocr");
|
||||||
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
|
formatHeaders().forEach((value, key) => {
|
||||||
|
xhr.setRequestHeader(key, value);
|
||||||
|
});
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QrGenerateSettings() {
|
export function QrGenerateSettings() {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [size, setSize] = useState(400);
|
const [size, setSize] = useState(400);
|
||||||
@@ -27,10 +23,7 @@ export function QrGenerateSettings() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/v1/tools/qr-generate", {
|
const res = await fetch("/api/v1/tools/qr-generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${getToken()}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ text, size, errorCorrection, foreground, background }),
|
body: JSON.stringify({ text, size, errorCorrection, foreground, background }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SplitSettings() {
|
export function SplitSettings() {
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
const [columns, setColumns] = useState(2);
|
const [columns, setColumns] = useState(2);
|
||||||
@@ -26,7 +22,7 @@ export function SplitSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/split", {
|
const res = await fetch("/api/v1/tools/split", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import { AlertTriangle, ChevronDown, ChevronRight, Download, Loader2, MapPin } f
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetadataResult {
|
interface MetadataResult {
|
||||||
filename: string;
|
filename: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
@@ -342,7 +339,7 @@ export function StripMetadataSettings() {
|
|||||||
formData.append("file", currentFile);
|
formData.append("file", currentFile);
|
||||||
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
|
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SvgToRasterSettings() {
|
export function SvgToRasterSettings() {
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
@@ -38,7 +34,7 @@ export function SvgToRasterSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/svg-to-raster", {
|
const res = await fetch("/api/v1/tools/svg-to-raster", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VectorizeSettings() {
|
export function VectorizeSettings() {
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
@@ -30,7 +26,7 @@ export function VectorizeSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/vectorize", {
|
const res = await fetch("/api/v1/tools/vectorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WatermarkImageSettings() {
|
export function WatermarkImageSettings() {
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
@@ -35,7 +31,7 @@ export function WatermarkImageSettings() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/tools/watermark-image", {
|
const res = await fetch("/api/v1/tools/watermark-image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -45,7 +46,7 @@ export function useAuth(): AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionRes = await fetch("/api/auth/session", {
|
const sessionRes = await fetch("/api/auth/session", {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sessionRes.ok) {
|
if (sessionRes.ok) {
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
|
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProcessResult {
|
interface ProcessResult {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
@@ -222,10 +219,9 @@ export function useToolProcessor(toolId: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.open("POST", `/api/v1/tools/${toolId}`);
|
xhr.open("POST", `/api/v1/tools/${toolId}`);
|
||||||
const token = getToken();
|
formatHeaders().forEach((value, key) => {
|
||||||
if (token) {
|
xhr.setRequestHeader(key, value);
|
||||||
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
});
|
||||||
}
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
},
|
},
|
||||||
[toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
|
[toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
|
||||||
@@ -292,10 +288,9 @@ export function useToolProcessor(toolId: string) {
|
|||||||
formData.append("clientJobId", clientJobId);
|
formData.append("clientJobId", clientJobId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = getToken();
|
|
||||||
const response = await fetch(`/api/v1/tools/${toolId}/batch`, {
|
const response = await fetch(`/api/v1/tools/${toolId}/batch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+29
-23
@@ -1,5 +1,26 @@
|
|||||||
const API_BASE = "/api";
|
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> {
|
async function throwWithMessage(res: Response): Promise<never> {
|
||||||
let msg = `API error: ${res.status}`;
|
let msg = `API error: ${res.status}`;
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +35,7 @@ async function throwWithMessage(res: Response): Promise<never> {
|
|||||||
|
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.ok) await throwWithMessage(res);
|
if (!res.ok) await throwWithMessage(res);
|
||||||
return res.json();
|
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> {
|
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${getToken()}`,
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
if (!res.ok) await throwWithMessage(res);
|
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> {
|
export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${getToken()}`,
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
if (!res.ok) await throwWithMessage(res);
|
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> {
|
export async function apiDelete<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: formatHeaders(),
|
||||||
Authorization: `Bearer ${getToken()}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!res.ok) await throwWithMessage(res);
|
if (!res.ok) await throwWithMessage(res);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setToken(token: string) {
|
export function setToken(token: string) {
|
||||||
localStorage.setItem("stirling-token", token);
|
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);
|
for (const f of files) formData.append("files", f);
|
||||||
const res = await fetch("/api/v1/upload", {
|
const res = await fetch("/api/v1/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
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);
|
for (const f of files) formData.append("files", f);
|
||||||
const res = await fetch("/api/v1/files/upload", {
|
const res = await fetch("/api/v1/files/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
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 }> {
|
export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> {
|
||||||
const res = await fetch("/api/v1/files", {
|
const res = await fetch("/api/v1/files", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${getToken()}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ids }),
|
body: JSON.stringify({ ids }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
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> {
|
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
||||||
const res = await fetch(getDownloadUrl(jobId, filename), {
|
const res = await fetch(getDownloadUrl(jobId, filename), {
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Play, Trash2, Workflow } from "lucide-react";
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { PipelineBuilder, type PipelineStep } from "@/components/tools/pipeline-builder";
|
import { PipelineBuilder, type PipelineStep } from "@/components/tools/pipeline-builder";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
|
|
||||||
interface SavedPipeline {
|
interface SavedPipeline {
|
||||||
@@ -11,11 +12,6 @@ interface SavedPipeline {
|
|||||||
steps: Array<{ toolId: string; settings: Record<string, unknown> }>;
|
steps: Array<{ toolId: string; settings: Record<string, unknown> }>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("stirling-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AutomatePage() {
|
export function AutomatePage() {
|
||||||
const [steps, setSteps] = useState<PipelineStep[]>([]);
|
const [steps, setSteps] = useState<PipelineStep[]>([]);
|
||||||
const [savedPipelines, setSavedPipelines] = useState<SavedPipeline[]>([]);
|
const [savedPipelines, setSavedPipelines] = useState<SavedPipeline[]>([]);
|
||||||
@@ -33,7 +29,7 @@ export function AutomatePage() {
|
|||||||
const loadPipelines = useCallback(async () => {
|
const loadPipelines = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/v1/pipeline/list", {
|
const res = await fetch("/api/v1/pipeline/list", {
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -55,10 +51,7 @@ export function AutomatePage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/v1/pipeline/save", {
|
const res = await fetch("/api/v1/pipeline/save", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${getToken()}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
@@ -86,7 +79,7 @@ export function AutomatePage() {
|
|||||||
try {
|
try {
|
||||||
await fetch(`/api/v1/pipeline/${id}`, {
|
await fetch(`/api/v1/pipeline/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
});
|
});
|
||||||
await loadPipelines();
|
await loadPipelines();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -117,7 +110,7 @@ export function AutomatePage() {
|
|||||||
|
|
||||||
const res = await fetch("/api/v1/pipeline/execute", {
|
const res = await fetch("/api/v1/pipeline/execute", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type FormEvent, useRef, useState } from "react";
|
import { type FormEvent, useRef, useState } from "react";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger the browser's "Save Password" prompt by submitting a real form
|
* Trigger the browser's "Save Password" prompt by submitting a real form
|
||||||
@@ -85,13 +86,9 @@ export function ChangePasswordPage() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("stirling-token");
|
|
||||||
const res = await fetch("/api/auth/change-password", {
|
const res = await fetch("/api/auth/change-password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+50
-11
@@ -5,6 +5,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
ARG VARIANT=full
|
ARG VARIANT=full
|
||||||
|
ARG GPU=false
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Build the frontend (Vite + React)
|
# 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
|
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 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
|
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)
|
# Python venv + ML packages + model weights (full variant only)
|
||||||
COPY packages/ai/python/requirements.txt /tmp/requirements.txt
|
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 \
|
RUN if [ "$VARIANT" = "full" ]; then \
|
||||||
python3 -m venv /opt/venv && \
|
python3 -m venv /opt/venv && \
|
||||||
/opt/venv/bin/pip install --upgrade pip && \
|
/opt/venv/bin/pip install --upgrade pip && \
|
||||||
/opt/venv/bin/pip install \
|
if [ "$GPU" = "true" ]; then \
|
||||||
Pillow numpy opencv-python-headless onnxruntime && \
|
/opt/venv/bin/pip install \
|
||||||
(/opt/venv/bin/pip install "rembg[cpu]" || echo "WARNING: rembg not installed") && \
|
Pillow numpy opencv-python-headless onnxruntime-gpu && \
|
||||||
(/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed") && \
|
(/opt/venv/bin/pip install rembg || echo "WARNING: rembg not installed") && \
|
||||||
(/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed") && \
|
(/opt/venv/bin/pip install realesrgan \
|
||||||
(/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \
|
--extra-index-url https://download.pytorch.org/whl/cu126 \
|
||||||
(/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \
|
|| echo "WARNING: realesrgan not installed") && \
|
||||||
; fi && rm -f /tmp/requirements.txt
|
(/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
|
COPY docker/download_models.py /tmp/download_models.py
|
||||||
RUN if [ "$VARIANT" = "full" ]; then \
|
RUN if [ "$VARIANT" = "full" ]; then \
|
||||||
@@ -152,7 +190,8 @@ ENV PORT=1349 \
|
|||||||
CONCURRENT_JOBS=3 \
|
CONCURRENT_JOBS=3 \
|
||||||
MAX_MEGAPIXELS=100 \
|
MAX_MEGAPIXELS=100 \
|
||||||
RATE_LIMIT_PER_MIN=100 \
|
RATE_LIMIT_PER_MIN=100 \
|
||||||
STIRLING_VARIANT=${VARIANT}
|
STIRLING_VARIANT=${VARIANT} \
|
||||||
|
STIRLING_GPU=${GPU}
|
||||||
|
|
||||||
# Create non-root user for runtime
|
# Create non-root user for runtime
|
||||||
RUN groupadd -r stirling && useradd -r -g stirling -d /app -s /sbin/nologin stirling
|
RUN groupadd -r stirling && useradd -r -g stirling -d /app -s /sbin/nologin stirling
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -39,6 +39,7 @@ def _try_import(name, import_fn):
|
|||||||
_try_import("PIL", lambda: __import__("PIL"))
|
_try_import("PIL", lambda: __import__("PIL"))
|
||||||
_try_import("cv2", lambda: __import__("cv2"))
|
_try_import("cv2", lambda: __import__("cv2"))
|
||||||
_try_import("numpy", lambda: __import__("numpy"))
|
_try_import("numpy", lambda: __import__("numpy"))
|
||||||
|
_try_import("gpu", lambda: __import__("gpu"))
|
||||||
|
|
||||||
# Heavy ML libraries - import but don't fail if unavailable
|
# Heavy ML libraries - import but don't fail if unavailable
|
||||||
_try_import("rembg", lambda: __import__("rembg"))
|
_try_import("rembg", lambda: __import__("rembg"))
|
||||||
@@ -123,8 +124,14 @@ def _run_script_main(script_name, args):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Signal readiness
|
# Signal readiness with GPU status
|
||||||
print(json.dumps({"ready": True}), file=sys.stderr, flush=True)
|
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:
|
for line in sys.stdin:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -34,9 +34,10 @@ def run_paddleocr(input_path, language):
|
|||||||
"""Run PaddleOCR."""
|
"""Run PaddleOCR."""
|
||||||
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
||||||
from paddleocr import PaddleOCR
|
from paddleocr import PaddleOCR
|
||||||
|
from gpu import gpu_available
|
||||||
|
|
||||||
emit_progress(20, "Loading")
|
emit_progress(20, "Loading")
|
||||||
ocr = PaddleOCR(lang=language)
|
ocr = PaddleOCR(lang=language, use_gpu=gpu_available())
|
||||||
emit_progress(30, "Scanning")
|
emit_progress(30, "Scanning")
|
||||||
result = ocr.ocr(input_path)
|
result = ocr.ocr(input_path)
|
||||||
emit_progress(70, "Extracting text")
|
emit_progress(70, "Extracting text")
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from rembg import remove, new_session
|
from rembg import remove, new_session
|
||||||
|
from gpu import onnx_providers
|
||||||
import io
|
import io
|
||||||
|
|
||||||
emit_progress(10, "Loading model")
|
emit_progress(10, "Loading model")
|
||||||
|
|
||||||
session = new_session(model)
|
session = new_session(model, providers=onnx_providers())
|
||||||
|
|
||||||
emit_progress(25, "Model loaded")
|
emit_progress(25, "Model loaded")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -26,7 +26,12 @@ def main():
|
|||||||
try:
|
try:
|
||||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
from realesrgan import RealESRGANer
|
from realesrgan import RealESRGANer
|
||||||
|
from gpu import gpu_available
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
use_gpu = gpu_available()
|
||||||
|
device = torch.device("cuda" if use_gpu else "cpu")
|
||||||
|
|
||||||
model = RRDBNet(
|
model = RRDBNet(
|
||||||
num_in_ch=3,
|
num_in_ch=3,
|
||||||
@@ -40,7 +45,8 @@ def main():
|
|||||||
scale=scale,
|
scale=scale,
|
||||||
model_path=None,
|
model_path=None,
|
||||||
model=model,
|
model=model,
|
||||||
half=False,
|
half=use_gpu,
|
||||||
|
device=device,
|
||||||
)
|
)
|
||||||
emit_progress(20, "Model ready")
|
emit_progress(20, "Model ready")
|
||||||
img_array = np.array(img.convert("RGB"))
|
img_array = np.array(img.convert("RGB"))
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ interface PendingRequest {
|
|||||||
let dispatcher: ChildProcess | null = null;
|
let dispatcher: ChildProcess | null = null;
|
||||||
let dispatcherReady = false;
|
let dispatcherReady = false;
|
||||||
let dispatcherFailed = false;
|
let dispatcherFailed = false;
|
||||||
|
// biome-ignore lint/style/useConst: reassigned on dispatcher readiness signal
|
||||||
|
let dispatcherGpuAvailable = false;
|
||||||
const pendingRequests = new Map<string, PendingRequest>();
|
const pendingRequests = new Map<string, PendingRequest>();
|
||||||
let stdoutBuffer = "";
|
let stdoutBuffer = "";
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ function startDispatcher(): ChildProcess | null {
|
|||||||
// Readiness signal
|
// Readiness signal
|
||||||
if (parsed.ready === true) {
|
if (parsed.ready === true) {
|
||||||
dispatcherReady = true;
|
dispatcherReady = true;
|
||||||
|
dispatcherGpuAvailable = parsed.gpu === true;
|
||||||
continue;
|
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.
|
* Shut down the persistent dispatcher process.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { removeBackground } from "./background-removal.js";
|
export { removeBackground } from "./background-removal.js";
|
||||||
export { shutdownDispatcher } from "./bridge.js";
|
export { isGpuAvailable, shutdownDispatcher } from "./bridge.js";
|
||||||
export { blurFaces } from "./face-detection.js";
|
export { blurFaces } from "./face-detection.js";
|
||||||
export { inpaint } from "./inpainting.js";
|
export { inpaint } from "./inpainting.js";
|
||||||
export { extractText } from "./ocr.js";
|
export { extractText } from "./ocr.js";
|
||||||
|
|||||||
@@ -525,18 +525,18 @@ describe("API lib", () => {
|
|||||||
|
|
||||||
const result = await apiGet<{ data: string }>("/v1/health");
|
const result = await apiGet<{ data: string }>("/v1/health");
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith("/api/v1/health", {
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
headers: { Authorization: "Bearer tok-123" },
|
expect(url).toBe("/api/v1/health");
|
||||||
});
|
expect(opts.headers.get("Authorization")).toBe("Bearer tok-123");
|
||||||
expect(result).toEqual({ data: "ok" });
|
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({}));
|
fetchMock.mockReturnValueOnce(okJson({}));
|
||||||
await apiGet("/v1/anything");
|
await apiGet("/v1/anything");
|
||||||
|
|
||||||
const callArgs = fetchMock.mock.calls[0];
|
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 () => {
|
it("throws on non-ok response (e.g., 401)", async () => {
|
||||||
@@ -567,8 +567,8 @@ describe("API lib", () => {
|
|||||||
const [url, opts] = fetchMock.mock.calls[0];
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
expect(url).toBe("/api/v1/items");
|
expect(url).toBe("/api/v1/items");
|
||||||
expect(opts.method).toBe("POST");
|
expect(opts.method).toBe("POST");
|
||||||
expect(opts.headers["Content-Type"]).toBe("application/json");
|
expect(opts.headers.get("Content-Type")).toBe("application/json");
|
||||||
expect(opts.headers.Authorization).toBe("Bearer post-tok");
|
expect(opts.headers.get("Authorization")).toBe("Bearer post-tok");
|
||||||
expect(opts.body).toBe(JSON.stringify({ name: "test" }));
|
expect(opts.body).toBe(JSON.stringify({ name: "test" }));
|
||||||
expect(result).toEqual({ id: 1 });
|
expect(result).toEqual({ id: 1 });
|
||||||
});
|
});
|
||||||
@@ -599,8 +599,8 @@ describe("API lib", () => {
|
|||||||
const [url, opts] = fetchMock.mock.calls[0];
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
expect(url).toBe("/api/v1/items/1");
|
expect(url).toBe("/api/v1/items/1");
|
||||||
expect(opts.method).toBe("PUT");
|
expect(opts.method).toBe("PUT");
|
||||||
expect(opts.headers["Content-Type"]).toBe("application/json");
|
expect(opts.headers.get("Content-Type")).toBe("application/json");
|
||||||
expect(opts.headers.Authorization).toBe("Bearer put-tok");
|
expect(opts.headers.get("Authorization")).toBe("Bearer put-tok");
|
||||||
expect(opts.body).toBe(JSON.stringify({ name: "updated" }));
|
expect(opts.body).toBe(JSON.stringify({ name: "updated" }));
|
||||||
expect(result).toEqual({ updated: true });
|
expect(result).toEqual({ updated: true });
|
||||||
});
|
});
|
||||||
@@ -623,7 +623,7 @@ describe("API lib", () => {
|
|||||||
const [url, opts] = fetchMock.mock.calls[0];
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
expect(url).toBe("/api/v1/items/1");
|
expect(url).toBe("/api/v1/items/1");
|
||||||
expect(opts.method).toBe("DELETE");
|
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(opts.body).toBeUndefined();
|
||||||
expect(result).toEqual({ deleted: true });
|
expect(result).toEqual({ deleted: true });
|
||||||
});
|
});
|
||||||
@@ -651,7 +651,7 @@ describe("API lib", () => {
|
|||||||
const [url, opts] = fetchMock.mock.calls[0];
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
expect(url).toBe("/api/v1/upload");
|
expect(url).toBe("/api/v1/upload");
|
||||||
expect(opts.method).toBe("POST");
|
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
|
// Body should be FormData
|
||||||
expect(opts.body).toBeInstanceOf(FormData);
|
expect(opts.body).toBeInstanceOf(FormData);
|
||||||
const fd = opts.body as FormData;
|
const fd = opts.body as FormData;
|
||||||
@@ -673,7 +673,7 @@ describe("API lib", () => {
|
|||||||
await apiUpload([makeFile("x.png")]);
|
await apiUpload([makeFile("x.png")]);
|
||||||
|
|
||||||
const headers = fetchMock.mock.calls[0][1].headers;
|
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 () => {
|
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];
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
expect(url).toBe("/api/v1/download/job-1/result.png");
|
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);
|
expect(result).toBe(blob);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -748,20 +748,20 @@ describe("API lib", () => {
|
|||||||
setToken("first-token");
|
setToken("first-token");
|
||||||
fetchMock.mockReturnValueOnce(okJson({}));
|
fetchMock.mockReturnValueOnce(okJson({}));
|
||||||
await apiGet("/v1/a");
|
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");
|
setToken("second-token");
|
||||||
fetchMock.mockReturnValueOnce(okJson({}));
|
fetchMock.mockReturnValueOnce(okJson({}));
|
||||||
await apiGet("/v1/b");
|
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");
|
setToken("about-to-die");
|
||||||
clearToken();
|
clearToken();
|
||||||
fetchMock.mockReturnValueOnce(okJson({}));
|
fetchMock.mockReturnValueOnce(okJson({}));
|
||||||
await apiGet("/v1/c");
|
await apiGet("/v1/c");
|
||||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer ");
|
expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user