Merge branch 'refactor/remove-lite-variant'

This commit is contained in:
Siddharth Kumar Sah
2026-04-10 22:36:39 +08:00
32 changed files with 311 additions and 424 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Bug Report
description: Something isn't working as expected
labels: ["bug"]
body:
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and didn't find a duplicate
required: true
- label: I'm running the latest version of Stirling Image
required: true
- type: textarea
id: description
attributes:
label: Description
description: What happened, and what did you expect to happen?
placeholder: |
When I try to resize an image, the output is blank.
I expected the resized image to be returned normally.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: How can we reproduce this?
placeholder: |
1. Upload a PNG image
2. Select "Resize" tool
3. Set width to 500
4. Click "Process"
5. Downloaded file is 0 bytes
validations:
required: true
- type: textarea
id: docker-setup
attributes:
label: Docker setup
description: Paste your `docker run` command or `docker-compose.yml` (remove any sensitive values).
render: shell
validations:
required: true
- type: input
id: image-tag
attributes:
label: Image tag
description: Which Docker image tag are you using?
placeholder: "e.g. latest, 1.11.0"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- AMD64 (Intel/AMD)
- ARM64 (Apple Silicon, Raspberry Pi)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
placeholder: "e.g. Chrome 130, Firefox 133, Safari 18"
- type: textarea
id: logs
attributes:
label: Logs
description: Paste any relevant container logs (`docker logs <container>`).
render: shell
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain the problem.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://stirling-image.github.io/stirling-image/
about: Check the docs for setup guides, configuration, and API reference.
- name: Security vulnerability
url: https://github.com/stirling-image/stirling-image/security/advisories/new
about: Report security issues privately through GitHub Security Advisories.
@@ -0,0 +1,45 @@
name: Feature Request
description: Suggest a new tool, improvement, or change
labels: ["enhancement"]
body:
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and didn't find a duplicate
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What problem does this solve? Describe the use case or frustration.
placeholder: |
I frequently need to convert AVIF files to JPEG, but there's no tool for it.
I currently have to use an external service which means my images leave my machine.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: How do you think this should work?
placeholder: |
Add an AVIF option to the format conversion tool, similar to how
HEIC and WebP are already supported.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Have you tried any workarounds? Are there other approaches that could work?
- type: textarea
id: context
attributes:
label: Additional context
description: Screenshots, links, examples, or anything else that helps explain the request.
+12
View File
@@ -0,0 +1,12 @@
<!--
Thanks for your interest in Stirling Image.
We don't accept pull requests. All development is handled internally to maintain architectural consistency and code quality.
If you've found a bug or have a feature idea, please open an issue instead:
https://github.com/stirling-image/stirling-image/issues
Your feedback is valuable and directly shapes the project.
-->
**This project does not accept pull requests.** Please close this PR and [open an issue](https://github.com/stirling-image/stirling-image/issues/new/choose) instead. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
+97 -19
View File
@@ -1,8 +1,7 @@
name: Release name: Release
on: on:
push: workflow_dispatch:
branches: [main]
permissions: permissions:
contents: write contents: write
@@ -14,7 +13,6 @@ jobs:
release: release:
name: Semantic Release name: Semantic Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
outputs: outputs:
new_version: ${{ steps.check.outputs.version }} new_version: ${{ steps.check.outputs.version }}
steps: steps:
@@ -46,14 +44,36 @@ jobs:
run: | run: |
if [ -f .release-version ]; then if [ -f .release-version ]; then
echo "version=$(cat .release-version)" >> "$GITHUB_OUTPUT" echo "version=$(cat .release-version)" >> "$GITHUB_OUTPUT"
else
echo "::error::semantic-release did not produce a new version. No releasable commits found."
exit 1
fi fi
docker: docker:
name: Docker Build & Push name: Build (${{ matrix.platform }})
needs: release needs: release
if: needs.release.outputs.new_version != '' strategy:
runs-on: ubuntu-latest fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps: steps:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/share/boost /opt/hostedtoolcache/CodeQL
sudo docker system prune -af
df -h /
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout release tag - name: Checkout release tag
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -62,8 +82,64 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Set up QEMU - name: Log in to Docker Hub
uses: docker/setup-qemu-action@v3 uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
stirlingimage/stirling-image
ghcr.io/${{ github.repository }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=stirlingimage/stirling-image,ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
manifest:
name: Create Multi-Arch Manifests
needs: [release, docker]
runs-on: ubuntu-latest
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -91,14 +167,16 @@ jobs:
type=semver,pattern={{major}},value=v${{ needs.release.outputs.new_version }} type=semver,pattern={{major}},value=v${{ needs.release.outputs.new_version }}
type=raw,value=latest type=raw,value=latest
- name: Build and push - name: Create Docker Hub manifest
uses: docker/build-push-action@v6 working-directory: /tmp/digests
with: run: |
context: . docker buildx imagetools create \
file: docker/Dockerfile $(jq -cr '.tags | map(select(startswith("stirlingimage/")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
push: true $(printf 'stirlingimage/stirling-image@sha256:%s ' *)
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} - name: Create GHCR manifest
labels: ${{ steps.meta.outputs.labels }} working-directory: /tmp/digests
cache-from: type=gha,scope=unified run: |
cache-to: type=gha,mode=max,scope=unified docker buildx imagetools create \
$(jq -cr '.tags | map(select(startswith("ghcr.io/")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
-2
View File
@@ -31,8 +31,6 @@ blob-report/
# IDE / tool scratch # IDE / tool scratch
.superpowers/ .superpowers/
.claude/
CLAUDE.md
docs/superpowers/ docs/superpowers/
PRD.md PRD.md
+1 -1
View File
@@ -744,7 +744,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed ### Removed
- Internal planning docs (`docs/superpowers/`) and Claude Code config (`.claude/`) from version control these stay local only - Internal planning docs (`docs/superpowers/`) and AI tool config from version control - these stay local only
## [0.7.0] - 2026-03-24 ## [0.7.0] - 2026-03-24
+12 -89
View File
@@ -1,102 +1,25 @@
# Contributing to Stirling Image # Contributing to Stirling Image
Thanks for your interest in contributing. There are many ways to help beyond writing code: reporting bugs, suggesting features, improving docs, and adding translations. Thanks for your interest in the project. Community feedback helps shape Stirling Image, and there are several ways to get involved.
## Issues ## How to contribute
Before opening an issue, search existing ones to avoid duplicates. The best way to contribute is through [GitHub Issues](https://github.com/stirling-image/stirling-image/issues):
- **Bug reports**: Include steps to reproduce, expected vs. actual behavior, and your environment (OS, Docker version, browser). - **Bug reports** - Found something broken? Open a bug report with steps to reproduce, your Docker setup, and what you expected to happen.
- **Feature requests**: Describe the problem you want solved, not just the solution. Context helps. - **Feature requests** - Have an idea for a new tool or improvement? Describe the problem you want solved and why it matters to you.
- **Questions**: Open an issue. We will do our best to respond quickly. - **Feedback** - Thoughts on the UI, workflow, documentation, or anything else? We want to hear it.
## Pull requests ## Pull requests
1. **Open an issue first.** Describe what you want to change and why. Wait for a maintainer to confirm the direction before writing code. We do not accept pull requests. All development is handled internally to maintain architectural consistency and code quality across the project.
2. **Fork the repo** and create a branch from `main`.
3. **Make your changes.** Follow the conventions in [CLAUDE.md](CLAUDE.md) (formatting, file structure, commit style).
4. **Test your changes.** Run `pnpm test` and `pnpm lint` before pushing. If you changed UI, run `pnpm test:e2e` too.
5. **Submit a PR.** Reference the issue number. Keep the title short and descriptive.
### Commit messages If you've found a bug, please open an issue describing it rather than submitting a fix. If you have a suggestion for how something should work, describe it in a feature request. Your input is valuable even without a code contribution.
We use [Conventional Commits](https://www.conventionalcommits.org/) for automated releases: ## Forking
- `feat:` new feature (triggers a minor version bump) You're welcome to fork the project for your own use under the terms of the [AGPLv3 license](LICENSE). The [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer) covers setup, architecture, and how to add new tools.
- `fix:` bug fix (triggers a patch version bump)
- `docs:` documentation only
- `test:` adding or fixing tests
- `refactor:` code change that doesn't fix a bug or add a feature
- `chore:` maintenance (CI, deps, config)
Example: `feat: add HEIC to PNG conversion support` ## Security
### What makes a good PR If you discover a security vulnerability, please report it privately through [GitHub Security Advisories](https://github.com/stirling-image/stirling-image/security/advisories/new) rather than opening a public issue.
- One logical change per PR. If you need to refactor something to add a feature, that can be one PR, but don't mix unrelated changes.
- Clear commit messages that explain why, not just what.
- Tests for new behavior when possible.
- No unrelated formatting changes (Biome handles formatting).
## Development setup
Full instructions are in the [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer). The short version:
```bash
git clone https://github.com/stirling-image/stirling-image.git
cd stirling-image
pnpm install
pnpm dev # starts both frontend and backend
```
The frontend runs at http://localhost:1349 and proxies API calls to the backend on port 13490.
### Running tests
```bash
pnpm lint # Biome lint + format check
pnpm typecheck # TypeScript across all workspaces
pnpm test # unit + integration tests
pnpm test:e2e # Playwright end-to-end tests
```
All of these run in CI on every PR. Make sure they pass locally first.
## Adding a new tool
Tools follow a consistent pattern. You will need to touch three places:
1. **Backend route** in `apps/api/src/routes/tools/` using `createToolRoute()` from the tool factory.
2. **Frontend settings component** in `apps/web/src/components/tools/` with the tool's UI controls.
3. **i18n entry** in `packages/shared/src/i18n/en.ts` with the tool's name and description.
See the [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer) for a walkthrough.
## Translations
We currently ship English only, but the i18n system is designed for easy extension. If you want to add a language, see the [Translation Guide](https://stirling-image.github.io/stirling-image/guide/translations).
## Code style
Biome handles formatting and linting. The rules are in `biome.json` and enforced by a pre-commit hook. Don't modify the Biome or TypeScript config files to silence warnings. Fix the code instead.
Quick summary:
- Double quotes, semicolons, 2-space indentation
- ES modules everywhere
- Zod for API input validation
- No `any` types without justification
## License and Contributor License Agreement
Stirling Image is dual-licensed under the [AGPLv3](LICENSE) and a commercial license. To keep this dual-licensing possible, all contributions must be submitted under the following terms:
By submitting a pull request or otherwise contributing code to this project, you agree that:
1. Your contributions are your original work (or you have the right to submit them).
2. You grant me a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license to use, reproduce, modify, distribute, sublicense, and relicense your contributions under any license, including the AGPLv3 and any commercial license I offer for this project.
3. You understand that your contributions will be publicly available under the AGPLv3 and may also be included in commercially licensed versions of the software.
This is necessary because the project offers a commercial license alongside the open-source AGPLv3. Without this agreement, contributed code could only be distributed under the AGPLv3, which would prevent offering a commercial option.
If you have questions about this, open an issue before contributing.
+7 -17
View File
@@ -42,22 +42,16 @@ docker run -d -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:l
Open http://localhost:1349 in your browser. Open http://localhost:1349 in your browser.
<details> <details>
<summary><sub>Looking for the Lite or CUDA image? Click here.</sub></summary> <summary><sub>Have an NVIDIA GPU? Click here for GPU acceleration.</sub></summary>
<br> <br>
**Lite** (~1.5 GB) - all image tools, no AI: Add `--gpus all` for GPU-accelerated background removal, upscaling, and OCR:
```bash ```bash
docker run -d -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:lite docker run -d -p 1349:1349 --gpus all -v stirling-data:/data stirlingimage/stirling-image:latest
``` ```
**CUDA** (~14 GB) - GPU-accelerated background removal, upscaling, and OCR: > Requires an NVIDIA GPU and [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](https://stirling-image.github.io/stirling-image/guide/docker-tags) for benchmarks and Docker Compose examples.
```bash
docker run -d -p 1349:1349 --gpus all -v stirling-data:/data stirlingimage/stirling-image:cuda
```
> CUDA needs an NVIDIA GPU and [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](https://stirling-image.github.io/stirling-image/guide/docker-tags) for benchmarks and Docker Compose examples.
</details> </details>
@@ -70,7 +64,7 @@ docker run -d -p 1349:1349 --gpus all -v stirling-data:/data stirlingimage/stirl
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 all image variants (full, lite, cuda), 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 GPU acceleration and tag details, see [Docker Tags](https://stirling-image.github.io/stirling-image/guide/docker-tags).
## Documentation ## Documentation
@@ -81,13 +75,9 @@ For Docker Compose, persistent storage, and other setup options, see the [Gettin
- [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer) - [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer)
- [Translation Guide](https://stirling-image.github.io/stirling-image/guide/translations) - [Translation Guide](https://stirling-image.github.io/stirling-image/guide/translations)
## Contributing ## Feedback
Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, the [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer) for setup, and the [Translation Guide](https://stirling-image.github.io/stirling-image/guide/translations) for adding languages. Found a bug or have a feature idea? Open a [GitHub Issue](https://github.com/stirling-image/stirling-image/issues). We don't accept pull requests, but your feedback directly shapes the project. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
## Support
Bug reports and feature requests: [GitHub Issues](https://github.com/stirling-image/stirling-image/issues)
<!-- TODO: Add sponsorship links once Ko-fi and GitHub Sponsors are set up --> <!-- TODO: Add sponsorship links once Ko-fi and GitHub Sponsors are set up -->
-8
View File
@@ -23,14 +23,6 @@ import { teamsRoutes } from "./routes/teams.js";
import { registerToolRoutes } from "./routes/tools/index.js"; import { registerToolRoutes } from "./routes/tools/index.js";
import { userFileRoutes } from "./routes/user-files.js"; import { userFileRoutes } from "./routes/user-files.js";
// Warn about deprecated STIRLING_VARIANT env var
if (process.env.STIRLING_VARIANT) {
console.warn(
`WARNING: STIRLING_VARIANT="${process.env.STIRLING_VARIANT}" is set but ignored. ` +
"There is now a single unified image with all features. Remove STIRLING_VARIANT from your environment.",
);
}
// Run before anything else // Run before anything else
runMigrations(); runMigrations();
console.log("Database initialized"); console.log("Database initialized");
+1 -5
View File
@@ -6,7 +6,6 @@
* GET /api/v1/settings/:key Get a specific setting * GET /api/v1/settings/:key Get a specific setting
*/ */
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
@@ -27,10 +26,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
settings[row.key] = row.value; settings[row.key] = row.value;
} }
const variant = process.env.STIRLING_VARIANT === "lite" ? "lite" : "full"; return reply.send({ settings });
const variantUnavailableTools = variant === "lite" ? [...PYTHON_SIDECAR_TOOLS] : [];
return reply.send({ settings, variant, variantUnavailableTools });
}); });
// PUT /api/v1/settings — Save settings (admin only) // PUT /api/v1/settings — Save settings (admin only)
+3 -21
View File
@@ -1,4 +1,4 @@
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@stirling-image/shared"; import { TOOLS } from "@stirling-image/shared";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { db, schema } from "../../db/index.js"; import { db, schema } from "../../db/index.js";
@@ -70,10 +70,6 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
// Build skip set // Build skip set
const skipTools = new Set([...disabledTools, ...(enableExperimental ? [] : experimentalToolIds)]); const skipTools = new Set([...disabledTools, ...(enableExperimental ? [] : experimentalToolIds)]);
// In lite mode, register 501 stubs for AI tools instead of real handlers
const isLite = process.env.STIRLING_VARIANT === "lite";
const liteStubTools = new Set<string>(PYTHON_SIDECAR_TOOLS);
const toolRegistrations: Array<{ const toolRegistrations: Array<{
id: string; id: string;
register: (app: FastifyInstance) => void; register: (app: FastifyInstance) => void;
@@ -133,7 +129,6 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
]; ];
let skipped = 0; let skipped = 0;
let stubbed = 0;
for (const { id, register } of toolRegistrations) { for (const { id, register } of toolRegistrations) {
if (skipTools.has(id)) { if (skipTools.has(id)) {
app.log.info(`Skipping disabled/experimental tool: ${id}`); app.log.info(`Skipping disabled/experimental tool: ${id}`);
@@ -141,24 +136,11 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
continue; continue;
} }
if (isLite && liteStubTools.has(id)) {
// Register a 501 stub instead of the real handler
app.post(`/api/v1/tools/${id}`, async (_request, reply) => {
return reply.status(501).send({
statusCode: 501,
error: "Not Available",
message: `The "${id}" tool requires the full image. Pull stirlingimage/stirling-image:latest for all features.`,
});
});
stubbed++;
continue;
}
register(app); register(app);
} }
const registered = toolRegistrations.length - skipped - stubbed; const registered = toolRegistrations.length - skipped;
app.log.info( app.log.info(
`Tool routes: ${registered} active, ${stubbed} lite-stubbed, ${skipped} skipped (${toolRegistrations.length} total)`, `Tool routes: ${registered} active, ${skipped} skipped (${toolRegistrations.length} total)`,
); );
} }
+1 -1
View File
@@ -46,7 +46,7 @@ export default defineConfig({
`, `,
customTemplateVariables: { customTemplateVariables: {
description: description:
"Self-hosted, open-source image processing platform with 30+ tools. Runs in a single Docker container. Available as :latest (full, ~11 GB with AI/ML) or :lite (~1.5 GB, image processing only).", "Self-hosted, open-source image processing platform with 30+ tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
details: details:
"Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.", "Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.",
}, },
+11 -4
View File
@@ -110,9 +110,16 @@ Set `client_max_body_size` to match your `MAX_UPLOAD_SIZE_MB` value.
## CI/CD ## CI/CD
The GitHub repository has two workflows: The GitHub repository has three workflows:
- **release.yml** -- On release, builds a multi-arch Docker image (amd64 + arm64), and pushes to Docker Hub (`stirlingimage/stirling-image`) and GitHub Container Registry (`ghcr.io/stirling-image/stirling-image`). - **ci.yml** -- Runs automatically on every push and PR. Lints, typechecks, tests, builds, and validates the Docker image (without pushing).
- **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages. - **release.yml** -- Triggered manually via `workflow_dispatch`. Runs semantic-release to create a version tag and GitHub release, then builds a multi-arch Docker image (amd64 + arm64) and pushes to Docker Hub (`stirlingimage/stirling-image`) and GitHub Container Registry (`ghcr.io/stirling-image/stirling-image`).
- **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages on push to `main`.
Both run automatically. No manual steps needed after merging to `main`. To create a release, go to **Actions > Release > Run workflow** in the GitHub UI, or run:
```bash
gh workflow run release.yml
```
Semantic-release determines the version from commit history. The `latest` Docker tag always points to the most recent release.
-6
View File
@@ -198,12 +198,6 @@ Build the full production image locally:
docker build -f docker/Dockerfile -t stirling-image:latest . docker build -f docker/Dockerfile -t stirling-image:latest .
``` ```
Build the lite image (no Python/AI, ~1.5 GB):
```bash
docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite .
```
Use BuildKit cache mounts for faster rebuilds: Use BuildKit cache mounts for faster rebuilds:
```bash ```bash
+1 -4
View File
@@ -118,9 +118,6 @@ volumes:
## Migration from previous tags ## Migration from previous tags
If you were using `:lite` or `:cuda` tags, switch to `:latest`: If you were using the `:cuda` tag, switch to `:latest` and keep `--gpus all`. Same GPU support, unified image.
- **From `:lite`**: Pull `:latest`. You now have all AI tools included.
- **From `:cuda`**: Pull `:latest` and keep `--gpus all`. Same GPU support, unified image.
Your data and settings are preserved in the volumes. Your data and settings are preserved in the volumes.
+2 -12
View File
@@ -14,21 +14,11 @@ docker run -d \
Open `http://localhost:1349` in your browser. Log in with `admin` / `admin`. Open `http://localhost:1349` in your browser. Log in with `admin` / `admin`.
::: tip Lite image
Don't need AI tools (background removal, upscaling, OCR, face blur, object eraser)? Use the lite image instead - 1.5 GB vs 11 GB:
```bash
stirlingimage/stirling-image:lite
```
All 27+ image processing tools work the same. See [Docker Tags](./docker-tags) for the full comparison.
:::
::: tip GPU acceleration ::: 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): Have an NVIDIA GPU? Add `--gpus all` to accelerate background removal (2.7x), upscaling (3x), and OCR (1.5x):
```bash ```bash
docker run -d --gpus all -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:cuda docker run -d --gpus all -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:latest
``` ```
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. 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.
+2 -43
View File
@@ -1,58 +1,17 @@
import type { Tool } from "@stirling-image/shared"; import type { Tool } from "@stirling-image/shared";
import * as icons from "lucide-react"; import * as icons from "lucide-react";
import { FileImage, Sparkles, Star } from "lucide-react"; import { FileImage, Star } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ToolCardProps { interface ToolCardProps {
tool: Tool; tool: Tool;
variantUnavailable?: boolean;
} }
export function ToolCard({ tool, variantUnavailable }: ToolCardProps) { export function ToolCard({ tool }: ToolCardProps) {
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>; const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
const IconComponent = iconsMap[tool.icon] || FileImage; const IconComponent = iconsMap[tool.icon] || FileImage;
if (variantUnavailable) {
return (
<div className="group flex items-center gap-3 relative">
<button
type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
title="Add to favourites"
>
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
</button>
<button
type="button"
onClick={() =>
toast("This tool requires the full image.", {
description:
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
action: {
label: "Learn more",
onClick: () =>
window.open(
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
"_blank",
),
},
})
}
className="flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors hover:bg-muted/50 opacity-50 cursor-pointer"
>
<IconComponent className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
<span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
<Sparkles className="h-2.5 w-2.5" />
AI
</span>
</button>
</div>
);
}
return ( return (
<div className="group flex items-center gap-3 relative"> <div className="group flex items-center gap-3 relative">
<button <button
+2 -31
View File
@@ -1,5 +1,4 @@
import { CATEGORIES, TOOLS } from "@stirling-image/shared"; import { CATEGORIES, TOOLS } from "@stirling-image/shared";
import { Info } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { SearchBar } from "../common/search-bar"; import { SearchBar } from "../common/search-bar";
@@ -7,15 +6,12 @@ import { ToolCard } from "../common/tool-card";
export function ToolPanel() { export function ToolPanel() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { variant, disabledTools, experimentalEnabled, variantUnavailableTools, loaded, fetch } = const { disabledTools, experimentalEnabled, loaded, fetch } = useSettingsStore();
useSettingsStore();
useEffect(() => { useEffect(() => {
fetch(); fetch();
}, [fetch]); }, [fetch]);
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
const visibleTools = useMemo(() => { const visibleTools = useMemo(() => {
if (!loaded) return []; if (!loaded) return [];
return TOOLS.filter((t) => { return TOOLS.filter((t) => {
@@ -49,27 +45,6 @@ export function ToolPanel() {
<SearchBar value={search} onChange={setSearch} /> <SearchBar value={search} onChange={setSearch} />
</div> </div>
<div className="px-3 pb-4 flex-1"> <div className="px-3 pb-4 flex-1">
{variant === "lite" && (
<div className="mb-3 p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-300">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<div className="text-xs leading-relaxed">
<p className="font-medium">Lite mode</p>
<p className="mt-0.5 text-amber-700 dark:text-amber-400">
AI tools are unavailable. Use the{" "}
<code className="font-mono text-[10px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">
latest
</code>{" "}
or{" "}
<code className="font-mono text-[10px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">
full
</code>{" "}
tag for all features.
</p>
</div>
</div>
</div>
)}
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => ( {CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
<div key={category.id} className="mb-4"> <div key={category.id} className="mb-4">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2"> <h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
@@ -77,11 +52,7 @@ export function ToolPanel() {
</h3> </h3>
<div className="space-y-0.5"> <div className="space-y-0.5">
{groupedTools.get(category.id)?.map((tool) => ( {groupedTools.get(category.id)?.map((tool) => (
<ToolCard <ToolCard key={tool.id} tool={tool} />
key={tool.id}
tool={tool}
variantUnavailable={unavailableSet.has(tool.id)}
/>
))} ))}
</div> </div>
</div> </div>
+6 -37
View File
@@ -1,12 +1,10 @@
import { CATEGORIES, TOOLS } from "@stirling-image/shared"; import { CATEGORIES, TOOLS } from "@stirling-image/shared";
import * as icons from "lucide-react"; import * as icons from "lucide-react";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { ImageViewer } from "@/components/common/image-viewer"; import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer"; import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { AppLayout } from "@/components/layout/app-layout"; import { AppLayout } from "@/components/layout/app-layout";
import { cn } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
@@ -17,14 +15,12 @@ export function HomePage() {
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } = const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } =
useFileStore(); useFileStore();
const navigate = useNavigate(); const navigate = useNavigate();
const { variantUnavailableTools, fetch: fetchSettings } = useSettingsStore(); const { fetch: fetchSettings } = useSettingsStore();
useEffect(() => { useEffect(() => {
fetchSettings(); fetchSettings();
}, [fetchSettings]); }, [fetchSettings]);
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
const handleFiles = useCallback( const handleFiles = useCallback(
(newFiles: File[]) => { (newFiles: File[]) => {
reset(); reset();
@@ -33,25 +29,6 @@ export function HomePage() {
[setFiles, reset], [setFiles, reset],
); );
const handleToolClick = (route: string, toolId: string) => {
if (unavailableSet.has(toolId)) {
toast("This tool requires the full image.", {
description:
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
action: {
label: "Learn more",
onClick: () =>
window.open(
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
"_blank",
),
},
});
return;
}
navigate(route);
};
const hasFile = files.length > 0; const hasFile = files.length > 0;
// If no file uploaded, show default layout (tool panel + dropzone) // If no file uploaded, show default layout (tool panel + dropzone)
@@ -103,11 +80,8 @@ export function HomePage() {
<button <button
key={id} key={id}
type="button" type="button"
onClick={() => handleToolClick(tool.route, tool.id)} onClick={() => navigate(tool.route)}
className={cn( className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
"flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left",
unavailableSet.has(id) && "opacity-50",
)}
> >
<div className="p-1.5 rounded-lg bg-primary/10 text-primary"> <div className="p-1.5 rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -148,13 +122,8 @@ export function HomePage() {
<button <button
key={tool.id} key={tool.id}
type="button" type="button"
onClick={() => handleToolClick(tool.route, tool.id)} onClick={() => navigate(tool.route)}
className={cn( className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors hover:bg-muted text-foreground"
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
unavailableSet.has(tool.id)
? "opacity-50 hover:bg-muted/50"
: "hover:bg-muted text-foreground",
)}
> >
<Icon className="h-4 w-4 text-muted-foreground shrink-0" /> <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm">{tool.name}</span> <span className="text-sm">{tool.name}</span>
+1 -9
View File
@@ -2,8 +2,6 @@ import { create } from "zustand";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
interface SettingsState { interface SettingsState {
variant: "full" | "lite";
variantUnavailableTools: string[];
disabledTools: string[]; disabledTools: string[];
experimentalEnabled: boolean; experimentalEnabled: boolean;
loaded: boolean; loaded: boolean;
@@ -11,8 +9,6 @@ interface SettingsState {
} }
export const useSettingsStore = create<SettingsState>((set, get) => ({ export const useSettingsStore = create<SettingsState>((set, get) => ({
variant: "full",
variantUnavailableTools: [],
disabledTools: [], disabledTools: [],
experimentalEnabled: false, experimentalEnabled: false,
loaded: false, loaded: false,
@@ -22,19 +18,15 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
try { try {
const data = await apiGet<{ const data = await apiGet<{
settings: Record<string, string>; settings: Record<string, string>;
variant: "full" | "lite";
variantUnavailableTools: string[];
}>("/v1/settings"); }>("/v1/settings");
set({ set({
variant: data.variant ?? "full",
variantUnavailableTools: data.variantUnavailableTools ?? [],
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [], disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
experimentalEnabled: data.settings.enableExperimentalTools === "true", experimentalEnabled: data.settings.enableExperimentalTools === "true",
loaded: true, loaded: true,
}); });
} catch { } catch {
// Settings fetch failed - default to full with no disabled tools // Settings fetch failed - default to no disabled tools
set({ loaded: true }); set({ loaded: true });
} }
}, },
+1 -2
View File
@@ -382,8 +382,7 @@ export const APP_VERSION = "1.13.0";
/** /**
* Tool IDs that require the Python sidecar (AI/ML tools). * Tool IDs that require the Python sidecar (AI/ML tools).
* Used by the API to register 501 stubs in lite mode, * Used by the frontend for progress/timeout behavior.
* and by the frontend for progress/timeout behavior.
*/ */
export const PYTHON_SIDECAR_TOOLS = [ export const PYTHON_SIDECAR_TOOLS = [
"remove-background", "remove-background",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { getTestImagePath } from "./helpers"; import { getTestImagePath } from "./helpers";
const API = "http://localhost:13490"; const API = process.env.API_URL || "http://localhost:13490";
async function getAuthToken(): Promise<string> { async function getAuthToken(): Promise<string> {
const res = await fetch(`${API}/api/auth/login`, { const res = await fetch(`${API}/api/auth/login`, {
+1 -1
View File
@@ -1,7 +1,7 @@
import { test as base, expect } from "@playwright/test"; import { test as base, expect } from "@playwright/test";
import { test as uiTest } from "./helpers"; import { test as uiTest } from "./helpers";
const API = "http://localhost:13490"; const API = process.env.API_URL || "http://localhost:13490";
async function getAuthToken(): Promise<string> { async function getAuthToken(): Promise<string> {
const res = await fetch(`${API}/api/auth/login`, { const res = await fetch(`${API}/api/auth/login`, {
+3 -3
View File
@@ -7,7 +7,7 @@ import { getTestImagePath } from "./helpers";
// auth token handling, and unauthenticated access. // auth token handling, and unauthenticated access.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const API = "http://localhost:13490"; const API = process.env.API_URL || "http://localhost:13490";
async function getAuthToken(): Promise<string> { async function getAuthToken(): Promise<string> {
const res = await fetch(`${API}/api/auth/login`, { const res = await fetch(`${API}/api/auth/login`, {
@@ -38,8 +38,8 @@ test.describe("Security: Path traversal", () => {
test("download rejects path traversal in jobId (..)", async () => { test("download rejects path traversal in jobId (..)", async () => {
const res = await fetch(`${API}/api/v1/download/../../../etc/passwd/file.png`); const res = await fetch(`${API}/api/v1/download/../../../etc/passwd/file.png`);
// Should return 400 (invalid path) or 404, never the actual file // 400/404 in dev (Fastify only), 200 in production (SPA fallback for normalized path).
expect([400, 404]).toContain(res.status); // Either way, the actual file must never be leaked.
const body = await res.text(); const body = await res.text();
expect(body).not.toContain("root:"); expect(body).not.toContain("root:");
}); });
@@ -2,7 +2,7 @@
* Integration tests for the content-aware resize (seam carving) API endpoint. * Integration tests for the content-aware resize (seam carving) API endpoint.
* *
* This tool uses the Python sidecar, so in CI/test environments where Python * This tool uses the Python sidecar, so in CI/test environments where Python
* is not available the route will return 501 (lite mode) or 422 (Python error). * is not available the route will return 422 (Python error).
* Tests gracefully handle both scenarios while still verifying route existence * Tests gracefully handle both scenarios while still verifying route existence
* and input validation. * and input validation.
*/ */
@@ -49,9 +49,9 @@ describe("Content-Aware Resize", () => {
body, body,
}); });
// 200 = Python available, 422 = Python error, 501 = lite mode stub // 200 = Python available, 422 = Python error
// Any of these proves the route is registered and reachable // Any of these proves the route is registered and reachable
expect([200, 422, 501]).toContain(res.statusCode); expect([200, 422]).toContain(res.statusCode);
}, 60_000); }, 60_000);
it("rejects requests without a file", async () => { it("rejects requests without a file", async () => {
@@ -88,8 +88,8 @@ describe("Content-Aware Resize", () => {
body, body,
}); });
// Accept 200 (Python available) or 422/501 (Python not available) // Accept 200 (Python available) or 422 (Python not available)
expect([200, 422, 501]).toContain(res.statusCode); expect([200, 422]).toContain(res.statusCode);
if (res.statusCode === 200) { if (res.statusCode === 200) {
const resBody = JSON.parse(res.body); const resBody = JSON.parse(res.body);
@@ -114,7 +114,7 @@ describe("Content-Aware Resize", () => {
body, body,
}); });
expect([200, 422, 501]).toContain(res.statusCode); expect([200, 422]).toContain(res.statusCode);
if (res.statusCode === 200) { if (res.statusCode === 200) {
const resBody = JSON.parse(res.body); const resBody = JSON.parse(res.body);
@@ -139,10 +139,10 @@ describe("Content-Aware Resize", () => {
body, body,
}); });
// 422 = Python caught the enlargement error, 501 = lite mode // 422 = Python caught the enlargement error
// Should never be 200 since 400 > 200px source width // Should never be 200 since 400 > 200px source width
expect(res.statusCode).not.toBe(200); expect(res.statusCode).not.toBe(200);
expect([422, 501]).toContain(res.statusCode); expect(res.statusCode).toBe(422);
if (res.statusCode === 422) { if (res.statusCode === 422) {
const resBody = JSON.parse(res.body); const resBody = JSON.parse(res.body);
-100
View File
@@ -1,100 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
describe("Lite variant", () => {
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
process.env.STIRLING_VARIANT = "lite";
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
delete process.env.STIRLING_VARIANT;
await testApp.cleanup();
}, 10_000);
describe("GET /api/v1/settings", () => {
it("includes variant and variantUnavailableTools", async () => {
const res = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.variant).toBe("lite");
expect(body.variantUnavailableTools).toEqual([
"remove-background",
"upscale",
"blur-faces",
"erase-object",
"ocr",
"content-aware-resize",
]);
});
});
describe("AI tool routes return 501", () => {
const aiTools = [
"remove-background",
"upscale",
"blur-faces",
"erase-object",
"ocr",
"content-aware-resize",
];
for (const toolId of aiTools) {
it(`POST /api/v1/tools/${toolId} returns 501`, async () => {
const res = await app.inject({
method: "POST",
url: `/api/v1/tools/${toolId}`,
headers: { authorization: `Bearer ${adminToken}` },
payload: {},
});
expect(res.statusCode).toBe(501);
const body = JSON.parse(res.body);
expect(body.error).toBe("Not Available");
expect(body.message).toContain("full image");
});
}
});
describe("Sharp tools still work in lite mode", () => {
it("POST /api/v1/tools/info returns 200 with valid image", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { fileURLToPath } = await import("node:url");
const __dirname = join(fileURLToPath(import.meta.url), "..");
const png = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png"));
const boundary = "----TestBoundary";
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="test.png"\r\nContent-Type: image/png\r\n\r\n`,
),
png,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/info",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": `multipart/form-data; boundary=${boundary}`,
},
payload: body,
});
expect(res.statusCode).toBe(200);
});
});
});