refactor: rename Tool.alpha to Tool.experimental

This commit is contained in:
Siddharth Kumar Sah 2026-03-25 09:27:12 +08:00
parent ab370a74fe
commit 585d66f0c9
178 changed files with 5637 additions and 4082 deletions

View file

@ -0,0 +1,13 @@
const fs = require("node:fs");
const input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const cmd = input.tool_input?.command || "";
const devPattern = /\b(pnpm|npm|yarn|bun)\s+(run\s+)?dev\b/;
if (devPattern.test(cmd) && !/tmux/.test(cmd) && !/&\s*$/.test(cmd)) {
console.log(
"TIP: Dev servers block the session. Consider running in tmux instead:\n" +
` tmux new-session -d -s stirling-dev '${cmd}'\n` +
"Or append & to background the process.",
);
}

View file

@ -0,0 +1,10 @@
const fs = require("node:fs");
const input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const cmd = input.tool_input?.command || "";
if (/--no-verify/.test(cmd)) {
console.log(
"BLOCKED: --no-verify bypasses git hooks. Fix the underlying issue instead of skipping checks.",
);
process.exit(2);
}

View file

@ -0,0 +1,20 @@
const fs = require("node:fs");
const input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const filePath = input.tool_input?.file_path || "";
const protectedPatterns = [
/biome\.json$/,
/\.eslintrc/,
/eslint\.config/,
/\.prettierrc/,
/prettier\.config/,
/tsconfig.*\.json$/,
/\.editorconfig$/,
];
if (protectedPatterns.some((p) => p.test(filePath))) {
console.log(
`BLOCKED: Cannot modify config file "${filePath}". Fix the code to match the config, not the other way around.`,
);
process.exit(2);
}

View file

@ -0,0 +1,21 @@
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const path = require("node:path");
const input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const filePath = input.tool_input?.file_path || "";
const formattable = /\.(ts|tsx|js|jsx|json)$/;
if (filePath && formattable.test(filePath) && fs.existsSync(filePath)) {
const projectRoot = path.resolve(__dirname, "..", "..");
const biomeBin = path.join(projectRoot, "node_modules", ".bin", "biome");
try {
execFileSync(biomeBin, ["check", "--write", filePath], {
stdio: "pipe",
cwd: projectRoot,
});
} catch {
// Format failures are non-blocking
}
}

View file

@ -0,0 +1,23 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const _input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const counterFile = path.join(os.tmpdir(), "stirling-claude-tool-count.json");
let count = 0;
try {
const data = JSON.parse(fs.readFileSync(counterFile, "utf8"));
count = data.count || 0;
} catch {
// First call or file missing
}
count++;
fs.writeFileSync(counterFile, JSON.stringify({ count }));
if (count === 50 || (count > 50 && (count - 50) % 25 === 0)) {
console.log(
`${count} tool calls this session. Consider /compact at a logical boundary to free up context.`,
);
}

28
.claude/settings.json Normal file
View file

@ -0,0 +1,28 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "node .claude/hooks/block-no-verify.js"
},
{
"matcher": "Bash",
"command": "node .claude/hooks/auto-tmux-dev.js"
},
{
"matcher": "Edit|Write",
"command": "node .claude/hooks/config-protection.js"
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"command": "node .claude/hooks/post-edit-format.js"
},
{
"matcher": "Edit|Write|Bash",
"command": "node .claude/hooks/suggest-compact.js"
}
]
}
}

2
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,2 @@
github: siddharthksah
ko_fi: siddharthksah

View file

@ -11,6 +11,22 @@ concurrency:
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
name: Typecheck
runs-on: ubuntu-latest
@ -27,9 +43,26 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:ci
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4

1
.gitignore vendored
View file

@ -27,6 +27,7 @@ blob-report/
# IDE / tool scratch
.superpowers/
.claude/settings.local.json
# Ad-hoc test screenshots and reports
test-*.png

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
pnpm lint-staged

102
CLAUDE.md Normal file
View file

@ -0,0 +1,102 @@
# Stirling-Image
Open-source, self-hostable image manipulation suite. Docker-first deployment.
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | React 19, Vite 6, Tailwind CSS 4, Zustand, react-router-dom v7 |
| Backend | Fastify 5, tsx (no compile step in dev), Sharp |
| Database | SQLite via Drizzle ORM (better-sqlite3) |
| AI/ML | Python sidecar (rembg, RealESRGAN, PaddleOCR, MediaPipe, LaMa) |
| Docs | VitePress |
| Testing | Vitest (unit/integration), Playwright (e2e) |
| CI/CD | GitHub Actions, semantic-release, Docker multi-arch |
| Linting | Biome (format + lint in one pass) |
## Monorepo Structure
```
apps/
api/ # Fastify backend (port 13490)
web/ # Vite + React frontend (port 1349, proxies /api to 13490)
docs/ # VitePress documentation
packages/
shared/ # Constants, types, i18n strings
image-engine/ # Sharp-based image operations
ai/ # Python sidecar bridge for ML models
tests/
unit/ # Vitest unit tests
integration/ # Vitest integration tests (full API)
e2e/ # Playwright e2e specs (13 files)
fixtures/ # Small test images
```
## Key Conventions
- **Simplicity over complexity** — do not over-engineer
- **Double quotes**, **semicolons**, **2-space indent** (enforced by Biome)
- **ES modules** in all workspaces (`"type": "module"`)
- Conventional commits for semantic-release (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`)
- API tool routes in `apps/api/src/routes/tools/`
- Tool UI components in `apps/web/src/components/tools/`
- i18n strings in `packages/shared/src/i18n/en.ts`
- Zod for all API input validation
## Commands
```bash
pnpm dev # Start all dev servers (web + api)
pnpm build # Build all workspaces
pnpm typecheck # TypeScript check across monorepo
pnpm lint # Biome lint + format check
pnpm lint:fix # Biome auto-fix lint + format issues
pnpm test # Vitest unit + integration tests
pnpm test:unit # Unit tests only
pnpm test:integration # Integration tests only
pnpm test:e2e # Playwright e2e tests
pnpm test:coverage # Tests with coverage report
```
## Database
SQLite via Drizzle ORM. Migrations in `apps/api/drizzle/`.
```bash
cd apps/api && npx drizzle-kit generate # Generate migration from schema changes
cd apps/api && npx drizzle-kit migrate # Apply pending migrations
```
Schema: `apps/api/src/db/schema.ts` — tables: users, sessions, settings, jobs, apiKeys, pipelines.
## Do Not Modify Config Files
Biome, TypeScript, and editor config files are protected by hooks. Fix the code to satisfy the linter/compiler, not the other way around. This prevents a common AI failure mode where rules get weakened instead of code getting fixed.
## Model Routing for Subagents
When spawning subagents via the Agent tool, use the cheapest model that can handle the task:
- **Haiku**: File search, simple lookups, grep operations, quick checks
- **Sonnet**: Standard development, test writing, refactoring, code review
- **Opus**: Complex architecture decisions, multi-file debugging, planning
This saves significant cost without losing quality on the main session.
## Strategic Compaction
When context gets large, compact at logical phase boundaries:
- **Good times to compact**: After research and before planning. After debugging and before implementing the fix. After completing a major feature.
- **Bad times to compact**: Mid-implementation. While actively debugging. During a multi-step refactor.
- **Survives compaction**: This CLAUDE.md, active tasks, git state, memory files
- **Lost on compaction**: Intermediate reasoning, file contents previously read, conversation flow
## Security
- Never commit `.env`, credentials, or API keys
- Validate user input at API boundaries with Zod schemas
- Use parameterized queries (Drizzle ORM handles this)
- SVG sanitization is already in place for uploads
- Rate limiting is configured on the API

View file

@ -113,6 +113,15 @@ Requires Node.js 22+ and pnpm 9+.
- **AI/ML:** Python (rembg, Real-ESRGAN, PaddleOCR, MediaPipe)
- **Infrastructure:** Turborepo monorepo, Docker multi-arch
## Support This Project
If Stirling Image is useful to you, consider supporting its development:
<p align="center">
<a href="https://github.com/sponsors/siddharthksah"><img src="https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=github-sponsors" alt="GitHub Sponsors"></a>
<a href="https://ko-fi.com/siddharthksah"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Ko--fi-FF5E5B?logo=ko-fi" alt="Ko-fi"></a>
</p>
## Contributing
Contributions are welcome. Please open an issue first to discuss what you'd like to change.

View file

@ -7,6 +7,7 @@
"dev": "PORT=13490 tsx watch src/index.ts",
"build": "tsc",
"start": "tsx src/index.ts",
"lint": "biome check src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},

View file

@ -1,7 +1,7 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./index.js";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

View file

@ -1,9 +1,9 @@
import { readdir, stat, rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdirSync } from "node:fs";
import { readdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { lt } from "drizzle-orm";
import { db, schema } from "../db/index.js";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
export function startCleanupCron() {
// Ensure workspace directory exists
@ -59,5 +59,7 @@ export function startCleanupCron() {
// Schedule recurring cleanup
setInterval(cleanup, intervalMs);
setInterval(purgeExpiredSessions, 60 * 60 * 1000); // Hourly
console.log(`Cleanup scheduled: every ${env.CLEANUP_INTERVAL_MINUTES}m, max age ${env.FILE_MAX_AGE_HOURS}h`);
console.log(
`Cleanup scheduled: every ${env.CLEANUP_INTERVAL_MINUTES}m, max age ${env.FILE_MAX_AGE_HOURS}h`,
);
}

View file

@ -2,15 +2,7 @@ import sharp from "sharp";
import { env } from "../config.js";
/** Formats we accept as input. */
const SUPPORTED_INPUT_FORMATS = new Set([
"jpeg",
"png",
"webp",
"gif",
"tiff",
"bmp",
"avif",
]);
const SUPPORTED_INPUT_FORMATS = new Set(["jpeg", "png", "webp", "gif", "tiff", "bmp", "avif"]);
interface MagicEntry {
bytes: number[];

View file

@ -1,8 +1,8 @@
import type { FastifyInstance } from "fastify";
import fastifyStatic from "@fastify/static";
import { resolve, dirname } from "node:path";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import fastifyStatic from "@fastify/static";
import type { FastifyInstance } from "fastify";
export async function registerStatic(app: FastifyInstance) {
// Resolve relative to this file's location

View file

@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart";
import type { FastifyInstance } from "fastify";
import { env } from "../config.js";
export async function registerUpload(app: FastifyInstance): Promise<void> {

View file

@ -6,91 +6,82 @@
* DELETE /api/v1/api-keys/:id Delete an API key
*/
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { eq, and } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { hashPassword, computeKeyPrefix, requireAuth } from "../plugins/auth.js";
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/api-keys — Generate a new API key
app.post(
"/api/v1/api-keys",
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.post("/api/v1/api-keys", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const body = request.body as { name?: string } | null;
const name = body?.name?.trim() || "Default API Key";
const body = request.body as { name?: string } | null;
const name = body?.name?.trim() || "Default API Key";
if (name.length > 100) {
return reply.status(400).send({
error: "Key name must be 100 characters or fewer",
code: "VALIDATION_ERROR",
});
}
// Generate a raw API key: "si_" prefix + 48 random bytes as hex
const rawKey = `si_${randomBytes(48).toString("hex")}`;
const keyHash = await hashPassword(rawKey);
const keyPrefix = computeKeyPrefix(rawKey);
const id = randomUUID();
db.insert(schema.apiKeys)
.values({
id,
userId: user.id,
keyHash,
keyPrefix,
name,
})
.run();
// Return the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({
id,
key: rawKey,
name,
createdAt: new Date().toISOString(),
if (name.length > 100) {
return reply.status(400).send({
error: "Key name must be 100 characters or fewer",
code: "VALIDATION_ERROR",
});
},
);
}
// Generate a raw API key: "si_" prefix + 48 random bytes as hex
const rawKey = `si_${randomBytes(48).toString("hex")}`;
const keyHash = await hashPassword(rawKey);
const keyPrefix = computeKeyPrefix(rawKey);
const id = randomUUID();
db.insert(schema.apiKeys)
.values({
id,
userId: user.id,
keyHash,
keyPrefix,
name,
})
.run();
// Return the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({
id,
key: rawKey,
name,
createdAt: new Date().toISOString(),
});
});
// GET /api/v1/api-keys — List user's API keys (never returns the key itself)
app.get(
"/api/v1/api-keys",
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.get("/api/v1/api-keys", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const keys = db
.select({
id: schema.apiKeys.id,
name: schema.apiKeys.name,
createdAt: schema.apiKeys.createdAt,
lastUsedAt: schema.apiKeys.lastUsedAt,
})
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id))
.all();
const keys = db
.select({
id: schema.apiKeys.id,
name: schema.apiKeys.name,
createdAt: schema.apiKeys.createdAt,
lastUsedAt: schema.apiKeys.lastUsedAt,
})
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id))
.all();
return reply.send({
apiKeys: keys.map((k) => ({
id: k.id,
name: k.name,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
})),
});
},
);
return reply.send({
apiKeys: keys.map((k) => ({
id: k.id,
name: k.name,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
})),
});
});
// DELETE /api/v1/api-keys/:id — Delete an API key
app.delete(
"/api/v1/api-keys/:id",
async (
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) => {
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
@ -110,9 +101,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
});
}
db.delete(schema.apiKeys)
.where(eq(schema.apiKeys.id, id))
.run();
db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)).run();
return reply.send({ ok: true });
},

View file

@ -8,30 +8,25 @@
* Returns a ZIP file containing all processed images.
*/
import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import archiver from "archiver";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import PQueue from "p-queue";
import { getToolConfig } from "./tool-factory.js";
import { env } from "../config.js";
import { autoOrient } from "../lib/auto-orient.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { autoOrient } from "../lib/auto-orient.js";
import { env } from "../config.js";
import { updateJobProgress, type JobProgress } from "./progress.js";
import { type JobProgress, updateJobProgress } from "./progress.js";
import { getToolConfig } from "./tool-factory.js";
interface ParsedFile {
buffer: Buffer;
filename: string;
}
export async function registerBatchRoutes(
app: FastifyInstance,
): Promise<void> {
export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
app.post(
"/api/v1/tools/:toolId/batch",
async (
request: FastifyRequest<{ Params: { toolId: string } }>,
reply: FastifyReply,
) => {
async (request: FastifyRequest<{ Params: { toolId: string } }>, reply: FastifyReply) => {
const { toolId } = request.params;
// Look up the tool config from the registry
@ -131,7 +126,7 @@ export async function registerBatchRoutes(
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Order": files.map(f => encodeURIComponent(f.filename)).join(","),
"X-File-Order": files.map((f) => encodeURIComponent(f.filename)).join(","),
});
// Create ZIP archive that pipes directly to the response
@ -193,11 +188,7 @@ export async function registerBatchRoutes(
try {
const orientedBuffer = await autoOrient(file.buffer);
const result = await toolConfig.process(
orientedBuffer,
settings,
file.filename,
);
const result = await toolConfig.process(orientedBuffer, settings, file.filename);
const zipFilename = getUniqueName(result.filename);
archive.append(result.buffer, { name: zipFilename });
@ -223,8 +214,7 @@ export async function registerBatchRoutes(
}
// Finalize progress
progress.status =
progress.failedFiles === progress.totalFiles ? "failed" : "completed";
progress.status = progress.failedFiles === progress.totalFiles ? "failed" : "completed";
progress.currentFile = undefined;
updateJobProgress({ ...progress });

View file

@ -1,10 +1,10 @@
import { randomUUID } from "node:crypto";
import { writeFile, readFile, stat } from "node:fs/promises";
import { join, extname } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
import { readFile, stat, writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
/**
* Guard against path traversal in URL params.
@ -20,67 +20,64 @@ function isPathTraversal(segment: string): boolean {
export async function fileRoutes(app: FastifyInstance): Promise<void> {
// ── POST /api/v1/upload ────────────────────────────────────────
app.post(
"/api/v1/upload",
async (request: FastifyRequest, reply: FastifyReply) => {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const inputDir = join(workspacePath, "input");
app.post("/api/v1/upload", async (request: FastifyRequest, reply: FastifyReply) => {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const inputDir = join(workspacePath, "input");
const uploadedFiles: Array<{
name: string;
size: number;
format: string;
}> = [];
const uploadedFiles: Array<{
name: string;
size: number;
format: string;
}> = [];
const parts = request.parts();
const parts = request.parts();
for await (const part of parts) {
// Skip non-file fields
if (part.type !== "file") continue;
for await (const part of parts) {
// Skip non-file fields
if (part.type !== "file") continue;
// Consume buffer from the stream
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Consume buffer from the stream
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Skip empty parts (e.g. empty file field)
if (buffer.length === 0) continue;
// Skip empty parts (e.g. empty file field)
if (buffer.length === 0) continue;
// Validate the image
const validation = await validateImageBuffer(buffer);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
});
}
// Sanitize filename
const safeName = sanitizeFilename(part.filename ?? "upload");
// Write to workspace input directory
const filePath = join(inputDir, safeName);
await writeFile(filePath, buffer);
uploadedFiles.push({
name: safeName,
size: buffer.length,
format: validation.format,
// Validate the image
const validation = await validateImageBuffer(buffer);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
});
}
if (uploadedFiles.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
// Sanitize filename
const safeName = sanitizeFilename(part.filename ?? "upload");
return reply.send({
jobId,
files: uploadedFiles,
// Write to workspace input directory
const filePath = join(inputDir, safeName);
await writeFile(filePath, buffer);
uploadedFiles.push({
name: safeName,
size: buffer.length,
format: validation.format,
});
},
);
}
if (uploadedFiles.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
return reply.send({
jobId,
files: uploadedFiles,
});
});
// ── GET /api/v1/download/:jobId/:filename ──────────────────────
app.get(
@ -119,10 +116,7 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
return reply
.header("Content-Type", contentType)
.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(filename)}"`,
)
.header("Content-Disposition", `attachment; filename="${encodeURIComponent(filename)}"`)
.send(buffer);
},
);

View file

@ -9,15 +9,15 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { getToolConfig } from "./tool-factory.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { createWorkspace } from "../lib/workspace.js";
import { sanitizeFilename } from "../lib/filename.js";
import { db, schema } from "../db/index.js";
import { requireAuth, getAuthUser } from "../plugins/auth.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { createWorkspace } from "../lib/workspace.js";
import { requireAuth } from "../plugins/auth.js";
import { getToolConfig } from "./tool-factory.js";
/** Schema for a single pipeline step. */
const pipelineStepSchema = z.object({
@ -27,14 +27,20 @@ const pipelineStepSchema = z.object({
/** Schema for a full pipeline definition. */
const pipelineDefinitionSchema = z.object({
steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step").max(20, "Pipeline cannot exceed 20 steps"),
steps: z
.array(pipelineStepSchema)
.min(1, "Pipeline must have at least one step")
.max(20, "Pipeline cannot exceed 20 steps"),
});
/** Schema for saving a pipeline. */
const savePipelineSchema = z.object({
name: z.string().min(1, "Pipeline name is required").max(100),
description: z.string().max(500).optional(),
steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step").max(20, "Pipeline cannot exceed 20 steps"),
steps: z
.array(pipelineStepSchema)
.min(1, "Pipeline must have at least one step")
.max(20, "Pipeline cannot exceed 20 steps"),
});
export async function registerPipelineRoutes(app: FastifyInstance): Promise<void> {
@ -49,161 +55,54 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
* The output of step N becomes the input of step N+1.
* Returns the final processed image for download.
*/
app.post(
"/api/v1/pipeline/execute",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let pipelineRaw: string | null = null;
app.post("/api/v1/pipeline/execute", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let pipelineRaw: string | null = null;
// Parse multipart
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "pipeline") {
pipelineRaw = part.value as string;
// Parse multipart
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
// Validate the initial image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid image: ${validation.reason}`,
});
}
// Parse and validate the pipeline definition
if (!pipelineRaw) {
return reply.status(400).send({ error: "No pipeline definition provided" });
}
let pipeline: z.infer<typeof pipelineDefinitionSchema>;
try {
const parsed = JSON.parse(pipelineRaw);
const result = pipelineDefinitionSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({
error: "Invalid pipeline definition",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
pipeline = result.data;
} catch {
return reply.status(400).send({ error: "Pipeline must be valid JSON" });
}
// Validate all tool IDs exist before starting
for (let i = 0; i < pipeline.steps.length; i++) {
const step = pipeline.steps[i];
const toolConfig = getToolConfig(step.toolId);
if (!toolConfig) {
return reply.status(400).send({
error: `Step ${i + 1}: Tool "${step.toolId}" not found`,
});
}
// Validate the settings for this tool
const settingsResult = toolConfig.settingsSchema.safeParse(step.settings);
if (!settingsResult.success) {
return reply.status(400).send({
error: `Step ${i + 1} (${step.toolId}): Invalid settings`,
details: settingsResult.error.issues.map((iss: { path: (string | number)[]; message: string }) => ({
path: iss.path.join("."),
message: iss.message,
})),
});
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "pipeline") {
pipelineRaw = part.value as string;
}
}
// Execute the pipeline: pass the buffer through each step sequentially
let currentBuffer = fileBuffer;
let currentFilename = filename;
const stepResults: Array<{ step: number; toolId: string; size: number }> = [];
try {
for (let i = 0; i < pipeline.steps.length; i++) {
const step = pipeline.steps[i];
const toolConfig = getToolConfig(step.toolId)!;
// Parse settings through the schema to apply defaults
const settings = toolConfig.settingsSchema.parse(step.settings);
const result = await toolConfig.process(currentBuffer, settings, currentFilename);
stepResults.push({
step: i + 1,
toolId: step.toolId,
size: result.buffer.length,
});
currentBuffer = result.buffer;
currentFilename = result.filename;
}
} catch (err) {
const message = err instanceof Error ? err.message : "Pipeline processing failed";
return reply.status(422).send({
error: "Pipeline processing failed",
details: message,
completedSteps: stepResults,
});
}
// Save the final output to workspace
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", currentFilename);
await writeFile(outputPath, currentBuffer);
// Also save the original input for reference
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(currentFilename)}`,
originalSize: fileBuffer.length,
processedSize: currentBuffer.length,
stepsCompleted: stepResults.length,
steps: stepResults,
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
},
);
}
/**
* POST /api/v1/pipeline/save
*
* Save a named pipeline definition for later reuse.
*/
app.post(
"/api/v1/pipeline/save",
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const body = request.body as unknown;
const result = savePipelineSchema.safeParse(body);
// Validate the initial image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid image: ${validation.reason}`,
});
}
// Parse and validate the pipeline definition
if (!pipelineRaw) {
return reply.status(400).send({ error: "No pipeline definition provided" });
}
let pipeline: z.infer<typeof pipelineDefinitionSchema>;
try {
const parsed = JSON.parse(pipelineRaw);
const result = pipelineDefinitionSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({
error: "Invalid pipeline definition",
@ -213,67 +112,170 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
})),
});
}
pipeline = result.data;
} catch {
return reply.status(400).send({ error: "Pipeline must be valid JSON" });
}
const { name, description, steps } = result.data;
// Validate all tool IDs exist
for (let i = 0; i < steps.length; i++) {
const toolConfig = getToolConfig(steps[i].toolId);
if (!toolConfig) {
return reply.status(400).send({
error: `Step ${i + 1}: Tool "${steps[i].toolId}" not found`,
});
}
// Validate all tool IDs exist before starting
for (let i = 0; i < pipeline.steps.length; i++) {
const step = pipeline.steps[i];
const toolConfig = getToolConfig(step.toolId);
if (!toolConfig) {
return reply.status(400).send({
error: `Step ${i + 1}: Tool "${step.toolId}" not found`,
});
}
const id = randomUUID();
// Validate the settings for this tool
const settingsResult = toolConfig.settingsSchema.safeParse(step.settings);
if (!settingsResult.success) {
return reply.status(400).send({
error: `Step ${i + 1} (${step.toolId}): Invalid settings`,
details: settingsResult.error.issues.map(
(iss: { path: (string | number)[]; message: string }) => ({
path: iss.path.join("."),
message: iss.message,
}),
),
});
}
}
db.insert(schema.pipelines)
.values({
id,
userId: user.id,
name,
description: description ?? null,
steps: JSON.stringify(steps),
})
.run();
// Execute the pipeline: pass the buffer through each step sequentially
let currentBuffer = fileBuffer;
let currentFilename = filename;
const stepResults: Array<{ step: number; toolId: string; size: number }> = [];
return reply.status(201).send({
try {
for (let i = 0; i < pipeline.steps.length; i++) {
const step = pipeline.steps[i];
const toolConfig = getToolConfig(step.toolId)!;
// Parse settings through the schema to apply defaults
const settings = toolConfig.settingsSchema.parse(step.settings);
const result = await toolConfig.process(currentBuffer, settings, currentFilename);
stepResults.push({
step: i + 1,
toolId: step.toolId,
size: result.buffer.length,
});
currentBuffer = result.buffer;
currentFilename = result.filename;
}
} catch (err) {
const message = err instanceof Error ? err.message : "Pipeline processing failed";
return reply.status(422).send({
error: "Pipeline processing failed",
details: message,
completedSteps: stepResults,
});
}
// Save the final output to workspace
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", currentFilename);
await writeFile(outputPath, currentBuffer);
// Also save the original input for reference
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(currentFilename)}`,
originalSize: fileBuffer.length,
processedSize: currentBuffer.length,
stepsCompleted: stepResults.length,
steps: stepResults,
});
});
/**
* POST /api/v1/pipeline/save
*
* Save a named pipeline definition for later reuse.
*/
app.post("/api/v1/pipeline/save", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const body = request.body as unknown;
const result = savePipelineSchema.safeParse(body);
if (!result.success) {
return reply.status(400).send({
error: "Invalid pipeline definition",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
const { name, description, steps } = result.data;
// Validate all tool IDs exist
for (let i = 0; i < steps.length; i++) {
const toolConfig = getToolConfig(steps[i].toolId);
if (!toolConfig) {
return reply.status(400).send({
error: `Step ${i + 1}: Tool "${steps[i].toolId}" not found`,
});
}
}
const id = randomUUID();
db.insert(schema.pipelines)
.values({
id,
userId: user.id,
name,
description: description ?? null,
steps,
createdAt: new Date().toISOString(),
});
},
);
steps: JSON.stringify(steps),
})
.run();
return reply.status(201).send({
id,
name,
description: description ?? null,
steps,
createdAt: new Date().toISOString(),
});
});
/**
* GET /api/v1/pipeline/list
*
* List all saved pipelines.
*/
app.get(
"/api/v1/pipeline/list",
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.get("/api/v1/pipeline/list", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
// Users see their own pipelines + legacy pipelines (no owner)
const rows = db.select().from(schema.pipelines).all()
.filter(row => !row.userId || row.userId === user.id);
// Users see their own pipelines + legacy pipelines (no owner)
const rows = db
.select()
.from(schema.pipelines)
.all()
.filter((row) => !row.userId || row.userId === user.id);
const pipelines = rows.map((row) => ({
id: row.id,
name: row.name,
description: row.description,
steps: JSON.parse(row.steps),
createdAt: row.createdAt.toISOString(),
}));
const pipelines = rows.map((row) => ({
id: row.id,
name: row.name,
description: row.description,
steps: JSON.parse(row.steps),
createdAt: row.createdAt.toISOString(),
}));
return reply.send({ pipelines });
},
);
return reply.send({ pipelines });
});
/**
* DELETE /api/v1/pipeline/:id
@ -282,20 +284,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
*/
app.delete(
"/api/v1/pipeline/:id",
async (
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) => {
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const { id } = request.params;
const existing = db
.select()
.from(schema.pipelines)
.where(eq(schema.pipelines.id, id))
.get();
const existing = db.select().from(schema.pipelines).where(eq(schema.pipelines.id, id)).get();
if (!existing) {
return reply.status(404).send({ error: "Pipeline not found" });
@ -306,9 +301,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
return reply.status(403).send({ error: "Not authorized to delete this pipeline" });
}
db.delete(schema.pipelines)
.where(eq(schema.pipelines.id, id))
.run();
db.delete(schema.pipelines).where(eq(schema.pipelines.id, id)).run();
return reply.send({ ok: true });
},

View file

@ -5,7 +5,7 @@
*
* Sends Server-Sent Events with progress data until the job finishes.
*/
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
export interface JobProgress {
jobId: string;
@ -32,10 +32,7 @@ export interface SingleFileProgress {
const jobProgressStore = new Map<string, JobProgress>();
/** SSE listeners waiting for updates, keyed by jobId. */
const listeners = new Map<
string,
Set<(data: JobProgress | SingleFileProgress) => void>
>();
const listeners = new Map<string, Set<(data: JobProgress | SingleFileProgress) => void>>();
/**
* Create or update progress for a job.
@ -58,9 +55,7 @@ export function updateJobProgress(progress: JobProgress): void {
}
}
export function updateSingleFileProgress(
progress: Omit<SingleFileProgress, "type">,
): void {
export function updateSingleFileProgress(progress: Omit<SingleFileProgress, "type">): void {
const event: SingleFileProgress = { ...progress, type: "single" };
const subs = listeners.get(progress.jobId);
if (subs) {
@ -75,15 +70,10 @@ export function updateSingleFileProgress(
}
}
export async function registerProgressRoutes(
app: FastifyInstance,
): Promise<void> {
export async function registerProgressRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/api/v1/jobs/:jobId/progress",
async (
request: FastifyRequest<{ Params: { jobId: string } }>,
reply: FastifyReply,
) => {
async (request: FastifyRequest<{ Params: { jobId: string } }>, reply: FastifyReply) => {
const { jobId } = request.params;
// Take over the response from Fastify for SSE streaming
@ -106,10 +96,7 @@ export async function registerProgressRoutes(
const existing = jobProgressStore.get(jobId);
if (existing) {
sendEvent(existing);
if (
existing.status === "completed" ||
existing.status === "failed"
) {
if (existing.status === "completed" || existing.status === "failed") {
reply.raw.end();
return;
}
@ -123,16 +110,14 @@ export async function registerProgressRoutes(
const callback = (data: JobProgress | SingleFileProgress) => {
sendEvent(data);
if (
("status" in data &&
(data.status === "completed" || data.status === "failed")) ||
("phase" in data &&
(data.phase === "complete" || data.phase === "failed"))
("status" in data && (data.status === "completed" || data.status === "failed")) ||
("phase" in data && (data.phase === "complete" || data.phase === "failed"))
) {
reply.raw.end();
}
};
listeners.get(jobId)!.add(callback);
listeners.get(jobId)?.add(callback);
// Clean up on client disconnect
request.raw.on("close", () => {

View file

@ -5,96 +5,78 @@
* PUT /api/v1/settings Save settings (admin only)
* GET /api/v1/settings/:key Get a specific setting
*/
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { requireAuth, requireAdmin } from "../plugins/auth.js";
import { requireAdmin, requireAuth } from "../plugins/auth.js";
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/settings — Get all settings as a key-value object
app.get(
"/api/v1/settings",
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
app.get("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const rows = db.select().from(schema.settings).all();
const rows = db.select().from(schema.settings).all();
const settings: Record<string, string> = {};
for (const row of rows) {
settings[row.key] = row.value;
}
const settings: Record<string, string> = {};
for (const row of rows) {
settings[row.key] = row.value;
}
return reply.send({ settings });
},
);
return reply.send({ settings });
});
// PUT /api/v1/settings — Save settings (admin only)
app.put(
"/api/v1/settings",
async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requireAdmin(request, reply);
if (!admin) return;
app.put("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requireAdmin(request, reply);
if (!admin) return;
const body = request.body as Record<string, unknown> | null;
const body = request.body as Record<string, unknown> | null;
if (!body || typeof body !== "object" || Array.isArray(body)) {
return reply.status(400).send({
error: "Request body must be a JSON object with key-value pairs",
code: "VALIDATION_ERROR",
});
}
if (!body || typeof body !== "object" || Array.isArray(body)) {
return reply.status(400).send({
error: "Request body must be a JSON object with key-value pairs",
code: "VALIDATION_ERROR",
});
}
const now = new Date();
let updatedCount = 0;
const now = new Date();
let updatedCount = 0;
for (const [key, value] of Object.entries(body)) {
if (typeof key !== "string" || key.length === 0) continue;
for (const [key, value] of Object.entries(body)) {
if (typeof key !== "string" || key.length === 0) continue;
const strValue = typeof value === "string" ? value : JSON.stringify(value);
const strValue = typeof value === "string" ? value : JSON.stringify(value);
// Upsert: insert or update on conflict
const existing = db
.select()
.from(schema.settings)
// Upsert: insert or update on conflict
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
if (existing) {
db.update(schema.settings)
.set({ value: strValue, updatedAt: now })
.where(eq(schema.settings.key, key))
.get();
if (existing) {
db.update(schema.settings)
.set({ value: strValue, updatedAt: now })
.where(eq(schema.settings.key, key))
.run();
} else {
db.insert(schema.settings)
.values({ key, value: strValue })
.run();
}
updatedCount++;
.run();
} else {
db.insert(schema.settings).values({ key, value: strValue }).run();
}
return reply.send({ ok: true, updatedCount });
},
);
updatedCount++;
}
return reply.send({ ok: true, updatedCount });
});
// GET /api/v1/settings/:key — Get a specific setting
app.get(
"/api/v1/settings/:key",
async (
request: FastifyRequest<{ Params: { key: string } }>,
reply: FastifyReply,
) => {
async (request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const { key } = request.params;
const row = db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, key))
.get();
const row = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
if (!row) {
return reply.status(404).send({

View file

@ -1,92 +1,86 @@
import sharp from "sharp";
import jsQR from "jsqr";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import jsQR from "jsqr";
import sharp from "sharp";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Read QR codes and barcodes from uploaded images.
*/
export function registerBarcodeRead(app: FastifyInstance) {
app.post(
"/api/v1/tools/barcode-read",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
app.post("/api/v1/tools/barcode-read", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
// Validate the uploaded image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
// Validate the uploaded image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
// Convert to RGBA raw pixel data for jsQR
const image = sharp(fileBuffer);
const metadata = await image.metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
try {
// Convert to RGBA raw pixel data for jsQR
const image = sharp(fileBuffer);
const metadata = await image.metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
const rawData = await image
.ensureAlpha()
.raw()
.toBuffer();
const rawData = await image.ensureAlpha().raw().toBuffer();
const code = jsQR(
new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
width,
height,
);
if (!code) {
return reply.send({
filename,
found: false,
text: null,
message: "No QR code found in the image",
});
}
const code = jsQR(
new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
width,
height,
);
if (!code) {
return reply.send({
filename,
found: true,
text: code.data,
location: {
topLeft: code.location.topLeftCorner,
topRight: code.location.topRightCorner,
bottomLeft: code.location.bottomLeftCorner,
bottomRight: code.location.bottomRightCorner,
},
});
} catch (err) {
return reply.status(422).send({
error: "Barcode reading failed",
details: err instanceof Error ? err.message : "Unknown error",
found: false,
text: null,
message: "No QR code found in the image",
});
}
},
);
return reply.send({
filename,
found: true,
text: code.data,
location: {
topLeft: code.location.topLeftCorner,
topRight: code.location.topRightCorner,
bottomLeft: code.location.bottomLeftCorner,
bottomRight: code.location.bottomRightCorner,
},
});
} catch (err) {
return reply.status(422).send({
error: "Barcode reading failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,116 +1,112 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import { blurFaces } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Face detection and blurring route.
* Uses MediaPipe for detection, PIL for blurring.
*/
export function registerBlurFaces(app: FastifyInstance) {
app.post(
"/api/v1/tools/blur-faces",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
app.post("/api/v1/tools/blur-faces", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await blurFaces(
fileBuffer,
join(workspacePath, "output"),
{
blurRadius: settings.blurRadius ?? 30,
sensitivity: settings.sensitivity ?? 0.5,
},
onProgress,
);
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await blurFaces(
fileBuffer,
join(workspacePath, "output"),
{
blurRadius: settings.blurRadius ?? 30,
sensitivity: settings.sensitivity ?? 0.5,
},
onProgress,
);
// Save output
const outputFilename =
filename.replace(/\.[^.]+$/, "") + "_blurred.png";
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
});
} catch (err) {
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
});
} catch (err) {
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,15 +1,21 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
borderWidth: z.number().min(0).max(200).default(10),
borderColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
borderColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
cornerRadius: z.number().min(0).max(500).default(0),
padding: z.number().min(0).max(200).default(0),
shadowBlur: z.number().min(0).max(50).default(0),
shadowColor: z.string().regex(/^#[0-9a-fA-F]{6,8}$/).default("#00000080"),
shadowColor: z
.string()
.regex(/^#[0-9a-fA-F]{6,8}$/)
.default("#00000080"),
});
export function registerBorder(app: FastifyInstance) {
@ -41,8 +47,8 @@ export function registerBorder(app: FastifyInstance) {
// If inner padding, overlay a background-colored rectangle for padding area
if (settings.padding > 0 && settings.borderWidth > 0) {
const outerW = w + totalBorder * 2 + shadowPad * 2;
const outerH = h + totalBorder * 2 + shadowPad * 2;
const _outerW = w + totalBorder * 2 + shadowPad * 2;
const _outerH = h + totalBorder * 2 + shadowPad * 2;
// Create a white padding region behind the image
const paddingRect = await sharp({
@ -85,13 +91,9 @@ export function registerBorder(app: FastifyInstance) {
</svg>`,
);
const maskBuffer = await sharp(roundedMask)
.resize(maskW, maskH)
.toBuffer();
const maskBuffer = await sharp(roundedMask).resize(maskW, maskH).toBuffer();
result = sharp(buf).composite([
{ input: maskBuffer, blend: "dest-in" },
]);
result = sharp(buf).composite([{ input: maskBuffer, blend: "dest-in" }]);
}
const buffer = await result.png().toBuffer();

View file

@ -1,8 +1,8 @@
import { z } from "zod";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { basename, extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
const settingsSchema = z.object({
pattern: z.string().min(1).max(200).default("image-{{index}}"),
@ -14,90 +14,89 @@ const settingsSchema = z.object({
* No image processing - just renames.
*/
export function registerBulkRename(app: FastifyInstance) {
app.post(
"/api/v1/tools/bulk-rename",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
app.post("/api/v1/tools/bulk-rename", async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `file-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `file-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No files provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
for (let i = 0; i < files.length; i++) {
const ext = extname(files[i].filename);
const index = settings.startIndex + i;
const padded = String(index).padStart(
String(files.length + settings.startIndex).length,
"0",
);
const newName =
settings.pattern
.replace(/\{\{index\}\}/g, String(index))
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) + ext;
archive.append(files[i].buffer, { name: basename(newName) });
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Rename failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No files provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
for (let i = 0; i < files.length; i++) {
const ext = extname(files[i].filename);
const index = settings.startIndex + i;
const padded = String(index).padStart(String(files.length + settings.startIndex).length, "0");
const newName =
settings.pattern
.replace(/\{\{index\}\}/g, String(index))
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) +
ext;
archive.append(files[i].buffer, { name: basename(newName) });
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Rename failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
}
},
);
}
});
}

View file

@ -1,16 +1,19 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
layout: z.enum(["2x2", "3x3", "1x3", "2x1", "3x1", "1x2"]).default("2x2"),
gap: z.number().min(0).max(50).default(4),
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
backgroundColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
});
function parseLayout(layout: string): { cols: number; rows: number } {
@ -19,126 +22,125 @@ function parseLayout(layout: string): { cols: number; rows: number } {
}
export function registerCollage(app: FastifyInstance) {
app.post(
"/api/v1/tools/collage",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
app.post("/api/v1/tools/collage", async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No images provided" });
}
// Validate all files
for (const file of files) {
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
if (files.length === 0) {
return reply.status(400).send({ error: "No images provided" });
}
// Validate all files
for (const file of files) {
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
return reply
.status(400)
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
}
}
try {
const { cols, rows } = parseLayout(settings.layout);
const totalSlots = cols * rows;
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// Determine cell size based on first image
const firstMeta = await sharp(files[0].buffer).metadata();
const cellW = firstMeta.width ?? 400;
const cellH = firstMeta.height ?? 400;
try {
const { cols, rows } = parseLayout(settings.layout);
const totalSlots = cols * rows;
// Canvas dimensions
const canvasW = cellW * cols + settings.gap * (cols + 1);
const canvasH = cellH * rows + settings.gap * (rows + 1);
// Determine cell size based on first image
const firstMeta = await sharp(files[0].buffer).metadata();
const cellW = firstMeta.width ?? 400;
const cellH = firstMeta.height ?? 400;
// Parse background color
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
// Canvas dimensions
const canvasW = cellW * cols + settings.gap * (cols + 1);
const canvasH = cellH * rows + settings.gap * (rows + 1);
// Create canvas
const composites: sharp.OverlayOptions[] = [];
// Parse background color
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
for (let i = 0; i < Math.min(files.length, totalSlots); i++) {
const row = Math.floor(i / cols);
const col = i % cols;
const x = settings.gap + col * (cellW + settings.gap);
const y = settings.gap + row * (cellH + settings.gap);
// Create canvas
const composites: sharp.OverlayOptions[] = [];
const resized = await sharp(files[i].buffer)
.resize(cellW, cellH, { fit: "cover" })
.toBuffer();
for (let i = 0; i < Math.min(files.length, totalSlots); i++) {
const row = Math.floor(i / cols);
const col = i % cols;
const x = settings.gap + col * (cellW + settings.gap);
const y = settings.gap + row * (cellH + settings.gap);
composites.push({ input: resized, top: y, left: x });
}
const result = await sharp({
create: {
width: canvasW,
height: canvasH,
channels: 3,
background: { r: bgR, g: bgG, b: bgB },
},
})
.composite(composites)
.png()
const resized = await sharp(files[i].buffer)
.resize(cellW, cellH, { fit: "cover" })
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "collage.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Collage creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
composites.push({ input: resized, top: y, left: x });
}
},
);
const result = await sharp({
create: {
width: canvasW,
height: canvasH,
channels: 3,
background: { r: bgR, g: bgG, b: bgB },
},
})
.composite(composites)
.png()
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "collage.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Collage creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,16 +1,16 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import {
brightness as adjustBrightness,
contrast as adjustContrast,
saturation as adjustSaturation,
colorChannels,
grayscale,
sepia,
invert,
sepia,
} from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
brightness: z.number().min(-100).max(100).default(0),
@ -19,9 +19,7 @@ const settingsSchema = z.object({
red: z.number().min(0).max(200).default(100),
green: z.number().min(0).max(200).default(100),
blue: z.number().min(0).max(200).default(100),
effect: z
.enum(["none", "grayscale", "sepia", "invert"])
.default("none"),
effect: z.enum(["none", "grayscale", "sepia", "invert"]).default("none"),
});
/**
@ -32,12 +30,7 @@ const settingsSchema = z.object({
*/
export function registerColorAdjustments(app: FastifyInstance) {
// Register the same handler under all four color-related tool IDs
const toolIds = [
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
];
const toolIds = ["brightness-contrast", "saturation", "color-channels", "color-effects"];
for (const toolId of toolIds) {
createToolRoute(app, {
@ -66,11 +59,7 @@ export function registerColorAdjustments(app: FastifyInstance) {
}
// Apply color channels (only if not default 100/100/100)
if (
settings.red !== 100 ||
settings.green !== 100 ||
settings.blue !== 100
) {
if (settings.red !== 100 || settings.green !== 100 || settings.blue !== 100) {
image = await colorChannels(image, {
red: settings.red,
green: settings.green,

View file

@ -1,6 +1,6 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
/**
* Simple k-means-like color quantization to extract dominant colors.
@ -19,16 +19,14 @@ function extractColors(pixels: Buffer, channelCount: number, maxColors: number):
}
// Sort by frequency and pick top colors
const sorted = [...colorMap.entries()]
.sort((a, b) => b[1] - a[1]);
const sorted = [...colorMap.entries()].sort((a, b) => b[1] - a[1]);
// Filter similar colors (merge colors within distance 40)
const results: Array<{ r: number; g: number; b: number; count: number }> = [];
for (const [key, count] of sorted) {
const [r, g, b] = key.split(",").map(Number);
const tooClose = results.some(
(c) =>
Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b) < 48,
(c) => Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b) < 48,
);
if (!tooClose) {
results.push({ r, g, b, count });
@ -43,56 +41,53 @@ function extractColors(pixels: Buffer, channelCount: number, maxColors: number):
}
export function registerColorPalette(app: FastifyInstance) {
app.post(
"/api/v1/tools/color-palette",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
app.post("/api/v1/tools/color-palette", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
// Resize to small image for analysis
const raw = await sharp(fileBuffer)
.resize(50, 50, { fit: "fill" })
.removeAlpha()
.raw()
.toBuffer();
try {
// Resize to small image for analysis
const raw = await sharp(fileBuffer)
.resize(50, 50, { fit: "fill" })
.removeAlpha()
.raw()
.toBuffer();
const colors = extractColors(raw, 3, 8);
const colors = extractColors(raw, 3, 8);
return reply.send({
filename,
colors,
count: colors.length,
});
} catch (err) {
return reply.status(422).send({
error: "Color extraction failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
filename,
colors,
count: colors.length,
});
} catch (err) {
return reply.status(422).send({
error: "Color extraction failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,112 +1,117 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { createWorkspace } from "../../lib/workspace.js";
/**
* Compare two images: compute a pixel-level diff and similarity score.
*/
export function registerCompare(app: FastifyInstance) {
app.post(
"/api/v1/tools/compare",
async (request, reply) => {
let bufferA: Buffer | null = null;
let bufferB: Buffer | null = null;
app.post("/api/v1/tools/compare", async (request, reply) => {
let bufferA: Buffer | null = null;
let bufferB: Buffer | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (!bufferA) {
bufferA = buf;
} else {
bufferB = buf;
}
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!bufferA || !bufferB) {
return reply.status(400).send({ error: "Two image files are required for comparison" });
}
try {
// Normalize both to same size for comparison
const metaA = await sharp(bufferA).metadata();
const metaB = await sharp(bufferB).metadata();
const w = Math.max(metaA.width ?? 100, metaB.width ?? 100);
const h = Math.max(metaA.height ?? 100, metaB.height ?? 100);
const rawA = await sharp(bufferA).resize(w, h, { fit: "fill" }).ensureAlpha().raw().toBuffer();
const rawB = await sharp(bufferB).resize(w, h, { fit: "fill" }).ensureAlpha().raw().toBuffer();
// Compute pixel diff
const diffPixels = Buffer.alloc(w * h * 4);
let totalDiff = 0;
const pixelCount = w * h;
for (let i = 0; i < rawA.length; i += 4) {
const dr = Math.abs(rawA[i] - rawB[i]);
const dg = Math.abs(rawA[i + 1] - rawB[i + 1]);
const db = Math.abs(rawA[i + 2] - rawB[i + 2]);
const pixelDiff = (dr + dg + db) / 3;
totalDiff += pixelDiff;
// Red tint for differences, transparent for identical
if (pixelDiff > 10) {
diffPixels[i] = 255; // R
diffPixels[i + 1] = 0; // G
diffPixels[i + 2] = 0; // B
diffPixels[i + 3] = Math.min(255, Math.round(pixelDiff * 3)); // A
const buf = Buffer.concat(chunks);
if (!bufferA) {
bufferA = buf;
} else {
// Slightly show original
diffPixels[i] = rawA[i];
diffPixels[i + 1] = rawA[i + 1];
diffPixels[i + 2] = rawA[i + 2];
diffPixels[i + 3] = 128;
bufferB = buf;
}
}
const similarity = Math.max(0, 100 - (totalDiff / (pixelCount * 255)) * 100);
const diffBuffer = await sharp(diffPixels, {
raw: { width: w, height: h, channels: 4 },
})
.png()
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const diffFilename = "diff.png";
const outputPath = join(workspacePath, "output", diffFilename);
await writeFile(outputPath, diffBuffer);
return reply.send({
jobId,
similarity: Math.round(similarity * 100) / 100,
dimensions: { width: w, height: h },
downloadUrl: `/api/v1/download/${jobId}/${diffFilename}`,
originalSize: bufferA.length + bufferB.length,
processedSize: diffBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Comparison failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!bufferA || !bufferB) {
return reply.status(400).send({ error: "Two image files are required for comparison" });
}
try {
// Normalize both to same size for comparison
const metaA = await sharp(bufferA).metadata();
const metaB = await sharp(bufferB).metadata();
const w = Math.max(metaA.width ?? 100, metaB.width ?? 100);
const h = Math.max(metaA.height ?? 100, metaB.height ?? 100);
const rawA = await sharp(bufferA)
.resize(w, h, { fit: "fill" })
.ensureAlpha()
.raw()
.toBuffer();
const rawB = await sharp(bufferB)
.resize(w, h, { fit: "fill" })
.ensureAlpha()
.raw()
.toBuffer();
// Compute pixel diff
const diffPixels = Buffer.alloc(w * h * 4);
let totalDiff = 0;
const pixelCount = w * h;
for (let i = 0; i < rawA.length; i += 4) {
const dr = Math.abs(rawA[i] - rawB[i]);
const dg = Math.abs(rawA[i + 1] - rawB[i + 1]);
const db = Math.abs(rawA[i + 2] - rawB[i + 2]);
const pixelDiff = (dr + dg + db) / 3;
totalDiff += pixelDiff;
// Red tint for differences, transparent for identical
if (pixelDiff > 10) {
diffPixels[i] = 255; // R
diffPixels[i + 1] = 0; // G
diffPixels[i + 2] = 0; // B
diffPixels[i + 3] = Math.min(255, Math.round(pixelDiff * 3)); // A
} else {
// Slightly show original
diffPixels[i] = rawA[i];
diffPixels[i + 1] = rawA[i + 1];
diffPixels[i + 2] = rawA[i + 2];
diffPixels[i + 3] = 128;
}
}
const similarity = Math.max(0, 100 - (totalDiff / (pixelCount * 255)) * 100);
const diffBuffer = await sharp(diffPixels, {
raw: { width: w, height: h, channels: 4 },
})
.png()
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const diffFilename = "diff.png";
const outputPath = join(workspacePath, "output", diffFilename);
await writeFile(outputPath, diffBuffer);
return reply.send({
jobId,
similarity: Math.round(similarity * 100) / 100,
dimensions: { width: w, height: h },
downloadUrl: `/api/v1/download/${jobId}/${diffFilename}`,
originalSize: bufferA.length + bufferB.length,
processedSize: diffBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Comparison failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,11 +1,11 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { sanitizeFilename } from "../../lib/filename.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
x: z.number().min(0).default(0),
@ -13,119 +13,125 @@ const settingsSchema = z.object({
opacity: z.number().min(0).max(100).default(100),
blendMode: z
.enum([
"over", "multiply", "screen", "overlay",
"darken", "lighten", "hard-light", "soft-light",
"difference", "exclusion",
"over",
"multiply",
"screen",
"overlay",
"darken",
"lighten",
"hard-light",
"soft-light",
"difference",
"exclusion",
])
.default("over"),
});
export function registerCompose(app: FastifyInstance) {
app.post(
"/api/v1/tools/compose",
async (request, reply) => {
let baseBuffer: Buffer | null = null;
let overlayBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/compose", async (request, reply) => {
let baseBuffer: Buffer | null = null;
let overlayBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "overlay") {
overlayBuffer = buf;
} else {
baseBuffer = buf;
filename = sanitizeFilename(part.filename ?? "image");
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "overlay") {
overlayBuffer = buf;
} else {
baseBuffer = buf;
filename = sanitizeFilename(part.filename ?? "image");
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!baseBuffer || baseBuffer.length === 0) {
return reply.status(400).send({ error: "No base image provided" });
}
if (!overlayBuffer || overlayBuffer.length === 0) {
return reply.status(400).send({ error: "No overlay image provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Apply opacity to overlay if needed
let processedOverlay = overlayBuffer;
if (settings.opacity < 100) {
const overlayImg = sharp(overlayBuffer).ensureAlpha();
const overlayBuf = await overlayImg.toBuffer();
const overlayMeta = await sharp(overlayBuf).metadata();
const oW = overlayMeta.width ?? 100;
const oH = overlayMeta.height ?? 100;
const opacityMask = await sharp({
create: {
width: oW,
height: oH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
},
})
.png()
.toBuffer();
processedOverlay = await sharp(overlayBuf)
.composite([{ input: opacityMask, blend: "dest-in" }])
.toBuffer();
}
if (!baseBuffer || baseBuffer.length === 0) {
return reply.status(400).send({ error: "No base image provided" });
}
if (!overlayBuffer || overlayBuffer.length === 0) {
return reply.status(400).send({ error: "No overlay image provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Apply opacity to overlay if needed
let processedOverlay = overlayBuffer;
if (settings.opacity < 100) {
const overlayImg = sharp(overlayBuffer).ensureAlpha();
const overlayBuf = await overlayImg.toBuffer();
const overlayMeta = await sharp(overlayBuf).metadata();
const oW = overlayMeta.width ?? 100;
const oH = overlayMeta.height ?? 100;
const opacityMask = await sharp({
create: {
width: oW,
height: oH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
},
})
.png()
.toBuffer();
processedOverlay = await sharp(overlayBuf)
.composite([{ input: opacityMask, blend: "dest-in" }])
.toBuffer();
}
const result = await sharp(baseBuffer)
.composite([{
const result = await sharp(baseBuffer)
.composite([
{
input: processedOverlay,
top: settings.y,
left: settings.x,
blend: settings.blendMode as import("sharp").Blend,
}])
.toBuffer();
},
])
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
originalSize: baseBuffer.length,
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
originalSize: baseBuffer.length,
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
}
});
}

View file

@ -1,8 +1,8 @@
import { compress } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { compress } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
mode: z.enum(["quality", "targetSize"]).default("quality"),

View file

@ -1,9 +1,9 @@
import { extname } from "node:path";
import { convert } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { convert } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { extname } from "node:path";
const FORMAT_CONTENT_TYPES: Record<string, string> = {
jpg: "image/jpeg",
@ -33,8 +33,7 @@ export function registerConvert(app: FastifyInstance) {
const baseName = ext ? filename.slice(0, -ext.length) : filename;
const outputFilename = `${baseName}.${settings.format}`;
const contentType =
FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return { buffer, filename: outputFilename, contentType };
},

View file

@ -1,8 +1,8 @@
import { crop } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { crop } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
left: z.number().int().min(0),

View file

@ -1,122 +1,118 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import { inpaint } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Object eraser / inpainting route.
* Accepts an image and a mask image, erases masked areas.
*/
export function registerEraseObject(app: FastifyInstance) {
app.post(
"/api/v1/tools/erase-object",
async (request: FastifyRequest, reply: FastifyReply) => {
let imageBuffer: Buffer | null = null;
let maskBuffer: Buffer | null = null;
let filename = "image";
let clientJobId: string | null = null;
app.post("/api/v1/tools/erase-object", async (request: FastifyRequest, reply: FastifyReply) => {
let imageBuffer: Buffer | null = null;
let maskBuffer: Buffer | null = null;
let filename = "image";
let clientJobId: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "mask") {
maskBuffer = buf;
} else {
imageBuffer = buf;
filename = basename(part.filename ?? "image");
}
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "mask") {
maskBuffer = buf;
} else {
imageBuffer = buf;
filename = basename(part.filename ?? "image");
}
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!imageBuffer || imageBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!maskBuffer || maskBuffer.length === 0) {
return reply.status(400).send({
error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'",
});
}
const imageValidation = await validateImageBuffer(imageBuffer);
if (!imageValidation.valid) {
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
}
const maskValidation = await validateImageBuffer(maskBuffer);
if (!maskValidation.valid) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
try {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, imageBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const resultBuffer = await inpaint(
imageBuffer,
maskBuffer,
join(workspacePath, "output"),
onProgress,
);
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_erased.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, resultBuffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (!imageBuffer || imageBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!maskBuffer || maskBuffer.length === 0) {
return reply
.status(400)
.send({ error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'" });
}
const imageValidation = await validateImageBuffer(imageBuffer);
if (!imageValidation.valid) {
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
}
const maskValidation = await validateImageBuffer(maskBuffer);
if (!maskValidation.valid) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
try {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, imageBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const resultBuffer = await inpaint(
imageBuffer,
maskBuffer,
join(workspacePath, "output"),
onProgress,
);
// Save output
const outputFilename =
filename.replace(/\.[^.]+$/, "") + "_erased.png";
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, resultBuffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: imageBuffer.length,
processedSize: resultBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: imageBuffer.length,
processedSize: resultBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,7 +1,7 @@
import sharp from "sharp";
import { randomUUID } from "node:crypto";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
const FAVICON_SIZES = [
{ name: "favicon-16x16.png", size: 16, format: "png" as const },
@ -13,97 +13,91 @@ const FAVICON_SIZES = [
];
export function registerFavicon(app: FastifyInstance) {
app.post(
"/api/v1/tools/favicon",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
app.post("/api/v1/tools/favicon", async (request, reply) => {
let fileBuffer: Buffer | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
const jobId = randomUUID();
try {
const jobId = randomUUID();
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
// Generate each size
for (const icon of FAVICON_SIZES) {
const buffer = await sharp(fileBuffer)
.resize(icon.size, icon.size, { fit: "cover" })
.png()
.toBuffer();
archive.append(buffer, { name: icon.name });
}
// Generate ICO (use 16x16 and 32x32 PNGs embedded)
// Simple ICO format: just include the 32x32 PNG as an ICO
const ico32 = await sharp(fileBuffer)
.resize(32, 32, { fit: "cover" })
// Generate each size
for (const icon of FAVICON_SIZES) {
const buffer = await sharp(fileBuffer)
.resize(icon.size, icon.size, { fit: "cover" })
.png()
.toBuffer();
archive.append(ico32, { name: "favicon.ico" });
// Generate manifest.json (for PWA)
const manifest = {
name: "App",
short_name: "App",
icons: [
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
],
theme_color: "#ffffff",
background_color: "#ffffff",
display: "standalone",
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
archive.append(buffer, { name: icon.name });
}
// Generate HTML snippet
const htmlSnippet = `<!-- Favicons -->
// Generate ICO (use 16x16 and 32x32 PNGs embedded)
// Simple ICO format: just include the 32x32 PNG as an ICO
const ico32 = await sharp(fileBuffer).resize(32, 32, { fit: "cover" }).png().toBuffer();
archive.append(ico32, { name: "favicon.ico" });
// Generate manifest.json (for PWA)
const manifest = {
name: "App",
short_name: "App",
icons: [
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
],
theme_color: "#ffffff",
background_color: "#ffffff",
display: "standalone",
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
// Generate HTML snippet
const htmlSnippet = `<!-- Favicons -->
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">
`;
archive.append(htmlSnippet, { name: "favicon-snippet.html" });
archive.append(htmlSnippet, { name: "favicon-snippet.html" });
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Favicon generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Favicon generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
});
}

View file

@ -1,17 +1,13 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
/**
* Compute a dHash (difference hash) for perceptual duplicate detection.
* Resize to 9x8 grayscale, compare adjacent pixels to create 64-bit hash.
*/
async function computeDHash(buffer: Buffer): Promise<string> {
const pixels = await sharp(buffer)
.resize(9, 8, { fit: "fill" })
.grayscale()
.raw()
.toBuffer();
const pixels = await sharp(buffer).resize(9, 8, { fit: "fill" }).grayscale().raw().toBuffer();
let hash = "";
for (let y = 0; y < 8; y++) {
@ -36,88 +32,87 @@ function hammingDistance(a: string, b: string): number {
}
export function registerFindDuplicates(app: FastifyInstance) {
app.post(
"/api/v1/tools/find-duplicates",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
app.post("/api/v1/tools/find-duplicates", async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length < 2) {
return reply
.status(400)
.send({ error: "At least 2 images are required for duplicate detection" });
}
try {
// Compute hashes for all images
const hashes: Array<{ filename: string; hash: string }> = [];
for (const file of files) {
const hash = await computeDHash(file.buffer);
hashes.push({ filename: file.filename, hash });
}
if (files.length < 2) {
return reply.status(400).send({ error: "At least 2 images are required for duplicate detection" });
}
// Compare all pairs, group duplicates
const threshold = 10; // Hamming distance threshold for "similar"
const groups: Array<{
files: Array<{ filename: string; similarity: number }>;
}> = [];
const assigned = new Set<number>();
try {
// Compute hashes for all images
const hashes: Array<{ filename: string; hash: string }> = [];
for (const file of files) {
const hash = await computeDHash(file.buffer);
hashes.push({ filename: file.filename, hash });
}
for (let i = 0; i < hashes.length; i++) {
if (assigned.has(i)) continue;
// Compare all pairs, group duplicates
const threshold = 10; // Hamming distance threshold for "similar"
const groups: Array<{
files: Array<{ filename: string; similarity: number }>;
}> = [];
const assigned = new Set<number>();
const group: Array<{ filename: string; similarity: number }> = [
{ filename: hashes[i].filename, similarity: 100 },
];
for (let i = 0; i < hashes.length; i++) {
if (assigned.has(i)) continue;
const group: Array<{ filename: string; similarity: number }> = [
{ filename: hashes[i].filename, similarity: 100 },
];
for (let j = i + 1; j < hashes.length; j++) {
if (assigned.has(j)) continue;
const dist = hammingDistance(hashes[i].hash, hashes[j].hash);
if (dist <= threshold) {
const similarity = Math.round((1 - dist / 64) * 10000) / 100;
group.push({ filename: hashes[j].filename, similarity });
assigned.add(j);
}
}
if (group.length > 1) {
assigned.add(i);
groups.push({ files: group });
for (let j = i + 1; j < hashes.length; j++) {
if (assigned.has(j)) continue;
const dist = hammingDistance(hashes[i].hash, hashes[j].hash);
if (dist <= threshold) {
const similarity = Math.round((1 - dist / 64) * 10000) / 100;
group.push({ filename: hashes[j].filename, similarity });
assigned.add(j);
}
}
return reply.send({
totalImages: files.length,
duplicateGroups: groups,
uniqueImages: files.length - assigned.size,
});
} catch (err) {
return reply.status(422).send({
error: "Duplicate detection failed",
details: err instanceof Error ? err.message : "Unknown error",
});
if (group.length > 1) {
assigned.add(i);
groups.push({ files: group });
}
}
},
);
return reply.send({
totalImages: files.length,
duplicateGroups: groups,
uniqueImages: files.length - assigned.size,
});
} catch (err) {
return reply.status(422).send({
error: "Duplicate detection failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,7 +1,7 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
width: z.number().min(1).max(4096).optional(),
@ -24,7 +24,7 @@ export function registerGifTools(app: FastifyInstance) {
}
const buffer = await image.png().toBuffer();
const outName = filename.replace(/\.gif$/i, "") + `_frame${settings.extractFrame}.png`;
const outName = `${filename.replace(/\.gif$/i, "")}_frame${settings.extractFrame}.png`;
return { buffer, filename: outName, contentType: "image/png" };
}

View file

@ -1,10 +1,10 @@
import { z } from "zod";
import sharp from "sharp";
import PDFDocument from "pdfkit";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import PDFDocument from "pdfkit";
import sharp from "sharp";
import { z } from "zod";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@ -21,128 +21,123 @@ const PAGE_SIZES: Record<string, [number, number]> = {
};
export function registerImageToPdf(app: FastifyInstance) {
app.post(
"/api/v1/tools/image-to-pdf",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
app.post("/api/v1/tools/image-to-pdf", async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No image files provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
let [pageW, pageH] = PAGE_SIZES[settings.pageSize] ?? PAGE_SIZES.A4;
if (settings.orientation === "landscape") {
[pageW, pageH] = [pageH, pageW];
}
const margin = settings.margin;
const contentW = pageW - margin * 2;
const contentH = pageH - margin * 2;
// Create PDF
const doc = new PDFDocument({
size: [pageW, pageH],
margin,
autoFirstPage: false,
});
const pdfChunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => pdfChunks.push(chunk));
const pdfDone = new Promise<Buffer>((resolve) => {
doc.on("end", () => resolve(Buffer.concat(pdfChunks)));
});
for (const file of files) {
doc.addPage({ size: [pageW, pageH], margin });
// Convert to PNG for PDFKit compatibility
const pngBuffer = await sharp(file.buffer).png().toBuffer();
const meta = await sharp(pngBuffer).metadata();
const imgW = meta.width ?? 100;
const imgH = meta.height ?? 100;
// Scale to fit within content area
const scale = Math.min(contentW / imgW, contentH / imgH, 1);
const scaledW = imgW * scale;
const scaledH = imgH * scale;
// Center on page
const x = margin + (contentW - scaledW) / 2;
const y = margin + (contentH - scaledH) / 2;
doc.image(pngBuffer, x, y, {
width: scaledW,
height: scaledH,
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No image files provided" });
}
doc.end();
const pdfBuffer = await pdfDone;
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "images.pdf";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, pdfBuffer);
try {
let [pageW, pageH] = PAGE_SIZES[settings.pageSize] ?? PAGE_SIZES.A4;
if (settings.orientation === "landscape") {
[pageW, pageH] = [pageH, pageW];
}
const margin = settings.margin;
const contentW = pageW - margin * 2;
const contentH = pageH - margin * 2;
// Create PDF
const doc = new PDFDocument({
size: [pageW, pageH],
margin,
autoFirstPage: false,
});
const pdfChunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => pdfChunks.push(chunk));
const pdfDone = new Promise<Buffer>((resolve) => {
doc.on("end", () => resolve(Buffer.concat(pdfChunks)));
});
for (const file of files) {
doc.addPage({ size: [pageW, pageH], margin });
// Convert to PNG for PDFKit compatibility
const pngBuffer = await sharp(file.buffer)
.png()
.toBuffer();
const meta = await sharp(pngBuffer).metadata();
const imgW = meta.width ?? 100;
const imgH = meta.height ?? 100;
// Scale to fit within content area
const scale = Math.min(contentW / imgW, contentH / imgH, 1);
const scaledW = imgW * scale;
const scaledH = imgH * scale;
// Center on page
const x = margin + (contentW - scaledW) / 2;
const y = margin + (contentH - scaledH) / 2;
doc.image(pngBuffer, x, y, {
width: scaledW,
height: scaledH,
});
}
doc.end();
const pdfBuffer = await pdfDone;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "images.pdf";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, pdfBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: pdfBuffer.length,
pages: files.length,
});
} catch (err) {
return reply.status(422).send({
error: "PDF creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: pdfBuffer.length,
pages: files.length,
});
} catch (err) {
return reply.status(422).send({
error: "PDF creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,44 +1,44 @@
import type { FastifyInstance } from "fastify";
import { registerResize } from "./resize.js";
import { registerCrop } from "./crop.js";
import { registerRotate } from "./rotate.js";
import { registerConvert } from "./convert.js";
import { registerCompress } from "./compress.js";
import { registerStripMetadata } from "./strip-metadata.js";
import { registerColorAdjustments } from "./color-adjustments.js";
// Phase 3: Watermark & Overlay
import { registerWatermarkText } from "./watermark-text.js";
import { registerWatermarkImage } from "./watermark-image.js";
import { registerTextOverlay } from "./text-overlay.js";
import { registerCompose } from "./compose.js";
// Phase 3: Utilities
import { registerInfo } from "./info.js";
import { registerCompare } from "./compare.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerColorPalette } from "./color-palette.js";
import { registerQrGenerate } from "./qr-generate.js";
import { registerBarcodeRead } from "./barcode-read.js";
// Phase 3: Layout & Composition
import { registerCollage } from "./collage.js";
import { registerSplit } from "./split.js";
import { registerBlurFaces } from "./blur-faces.js";
import { registerBorder } from "./border.js";
// Phase 3: Format & Conversion
import { registerSvgToRaster } from "./svg-to-raster.js";
import { registerVectorize } from "./vectorize.js";
import { registerGifTools } from "./gif-tools.js";
// Phase 3: Optimization extras
import { registerBulkRename } from "./bulk-rename.js";
// Phase 3: Layout & Composition
import { registerCollage } from "./collage.js";
import { registerColorAdjustments } from "./color-adjustments.js";
import { registerColorPalette } from "./color-palette.js";
import { registerCompare } from "./compare.js";
import { registerCompose } from "./compose.js";
import { registerCompress } from "./compress.js";
import { registerConvert } from "./convert.js";
import { registerCrop } from "./crop.js";
import { registerEraseObject } from "./erase-object.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
import { registerGifTools } from "./gif-tools.js";
import { registerImageToPdf } from "./image-to-pdf.js";
// Phase 3: Adjustments extra
import { registerReplaceColor } from "./replace-color.js";
// Phase 3: Utilities
import { registerInfo } from "./info.js";
import { registerOcr } from "./ocr.js";
import { registerQrGenerate } from "./qr-generate.js";
// Phase 4: AI Tools
import { registerRemoveBackground } from "./remove-background.js";
import { registerUpscale } from "./upscale.js";
import { registerOcr } from "./ocr.js";
import { registerBlurFaces } from "./blur-faces.js";
import { registerEraseObject } from "./erase-object.js";
// Phase 3: Adjustments extra
import { registerReplaceColor } from "./replace-color.js";
import { registerResize } from "./resize.js";
import { registerRotate } from "./rotate.js";
import { registerSmartCrop } from "./smart-crop.js";
import { registerSplit } from "./split.js";
import { registerStripMetadata } from "./strip-metadata.js";
// Phase 3: Format & Conversion
import { registerSvgToRaster } from "./svg-to-raster.js";
import { registerTextOverlay } from "./text-overlay.js";
import { registerUpscale } from "./upscale.js";
import { registerVectorize } from "./vectorize.js";
import { registerWatermarkImage } from "./watermark-image.js";
// Phase 3: Watermark & Overlay
import { registerWatermarkText } from "./watermark-text.js";
/**
* Registry that imports and registers all tool routes.

View file

@ -1,80 +1,77 @@
import sharp from "sharp";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
/**
* Image info route - read-only, returns JSON metadata.
* Does NOT use createToolRoute since it doesn't produce a processed file.
*/
export function registerInfo(app: FastifyInstance) {
app.post(
"/api/v1/tools/info",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
app.post("/api/v1/tools/info", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
const metadata = await sharp(fileBuffer).metadata();
const stats = await sharp(fileBuffer).stats();
try {
const metadata = await sharp(fileBuffer).metadata();
const stats = await sharp(fileBuffer).stats();
// Build histogram data from stats
const histogram = stats.channels.map((ch, i) => ({
channel: ["red", "green", "blue", "alpha"][i] ?? `channel-${i}`,
min: ch.min,
max: ch.max,
mean: Math.round(ch.mean * 100) / 100,
stdev: Math.round(ch.stdev * 100) / 100,
}));
// Build histogram data from stats
const histogram = stats.channels.map((ch, i) => ({
channel: ["red", "green", "blue", "alpha"][i] ?? `channel-${i}`,
min: ch.min,
max: ch.max,
mean: Math.round(ch.mean * 100) / 100,
stdev: Math.round(ch.stdev * 100) / 100,
}));
return reply.send({
filename,
fileSize: fileBuffer.length,
width: metadata.width ?? 0,
height: metadata.height ?? 0,
format: metadata.format ?? "unknown",
channels: metadata.channels ?? 0,
hasAlpha: metadata.hasAlpha ?? false,
colorSpace: metadata.space ?? "unknown",
density: metadata.density ?? null,
isProgressive: metadata.isProgressive ?? false,
orientation: metadata.orientation ?? null,
hasProfile: metadata.hasProfile ?? false,
hasExif: !!metadata.exif,
hasIcc: !!metadata.icc,
hasXmp: !!metadata.xmp,
bitDepth: metadata.depth ?? null,
pages: metadata.pages ?? 1,
histogram,
});
} catch (err) {
return reply.status(422).send({
error: "Failed to read image metadata",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
filename,
fileSize: fileBuffer.length,
width: metadata.width ?? 0,
height: metadata.height ?? 0,
format: metadata.format ?? "unknown",
channels: metadata.channels ?? 0,
hasAlpha: metadata.hasAlpha ?? false,
colorSpace: metadata.space ?? "unknown",
density: metadata.density ?? null,
isProgressive: metadata.isProgressive ?? false,
orientation: metadata.orientation ?? null,
hasProfile: metadata.hasProfile ?? false,
hasExif: !!metadata.exif,
hasIcc: !!metadata.icc,
hasXmp: !!metadata.xmp,
bitDepth: metadata.depth ?? null,
pages: metadata.pages ?? 1,
histogram,
});
} catch (err) {
return reply.status(422).send({
error: "Failed to read image metadata",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,11 +1,11 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import { z } from "zod";
import { extractText } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
const settingsSchema = z.object({
engine: z.enum(["tesseract", "paddleocr"]).default("tesseract"),
@ -17,103 +17,102 @@ const settingsSchema = z.object({
* Returns JSON with extracted text rather than an image.
*/
export function registerOcr(app: FastifyInstance) {
app.post(
"/api/v1/tools/ocr",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
app.post("/api/v1/tools/ocr", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
let settings: z.infer<typeof settingsSchema>;
try {
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
: undefined;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await extractText(
fileBuffer,
workspacePath,
{
engine: settings.engine,
language: settings.language,
},
onProgress,
);
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await extractText(
fileBuffer,
workspacePath,
{
engine: settings.engine,
language: settings.language,
},
onProgress,
);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
filename,
text: result.text,
engine: result.engine,
});
} catch (err) {
return reply.status(422).send({
error: "OCR failed",
details: err instanceof Error ? err.message : "Unknown error",
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
},
);
return reply.send({
jobId,
filename,
text: result.text,
engine: result.engine,
});
} catch (err) {
return reply.status(422).send({
error: "OCR failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,17 +1,23 @@
import { z } from "zod";
import QRCode from "qrcode";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import QRCode from "qrcode";
import { z } from "zod";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
text: z.string().min(1).max(2000),
size: z.number().min(100).max(2000).default(400),
errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"),
foreground: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
background: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
foreground: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
background: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
});
/**
@ -19,59 +25,56 @@ const settingsSchema = z.object({
* images from text input, not from uploaded files.
*/
export function registerQrGenerate(app: FastifyInstance) {
app.post(
"/api/v1/tools/qr-generate",
async (request: FastifyRequest, reply: FastifyReply) => {
let body: unknown;
try {
body = request.body;
} catch {
return reply.status(400).send({ error: "Invalid request body" });
}
app.post("/api/v1/tools/qr-generate", async (request: FastifyRequest, reply: FastifyReply) => {
let body: unknown;
try {
body = request.body;
} catch {
return reply.status(400).send({ error: "Invalid request body" });
}
const result = settingsSchema.safeParse(body);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
const result = settingsSchema.safeParse(body);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
const settings = result.data;
const settings = result.data;
try {
const buffer = await QRCode.toBuffer(settings.text, {
width: settings.size,
errorCorrectionLevel: settings.errorCorrection,
color: {
dark: settings.foreground,
light: settings.background,
},
type: "png",
margin: 2,
});
try {
const buffer = await QRCode.toBuffer(settings.text, {
width: settings.size,
errorCorrectionLevel: settings.errorCorrection,
color: {
dark: settings.foreground,
light: settings.background,
},
type: "png",
margin: 2,
});
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "qrcode.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, buffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "qrcode.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: 0,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "QR code generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: 0,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "QR code generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,11 +1,11 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import { removeBackground } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* AI background removal route.
@ -81,7 +81,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
);
// Save output
const outputFilename = filename.replace(/\.[^.]+$/, "") + "_nobg.png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, resultBuffer);

View file

@ -1,11 +1,17 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
sourceColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FF0000"),
targetColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#00FF00"),
sourceColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FF0000"),
targetColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#00FF00"),
makeTransparent: z.boolean().default(false),
tolerance: z.number().min(0).max(255).default(30),
});
@ -19,8 +25,12 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } {
}
function colorDistance(
r1: number, g1: number, b1: number,
r2: number, g2: number, b2: number,
r1: number,
g1: number,
b1: number,
r2: number,
g2: number,
b2: number,
): number {
return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2);
}
@ -42,8 +52,12 @@ export function registerReplaceColor(app: FastifyInstance) {
for (let i = 0; i < pixels.length; i += 4) {
const dist = colorDistance(
pixels[i], pixels[i + 1], pixels[i + 2],
source.r, source.g, source.b,
pixels[i],
pixels[i + 1],
pixels[i + 2],
source.r,
source.g,
source.b,
);
if (dist <= maxDist) {

View file

@ -1,15 +1,13 @@
import { resize } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { resize } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
width: z.number().positive().optional(),
height: z.number().positive().optional(),
fit: z
.enum(["contain", "cover", "fill", "inside", "outside"])
.default("contain"),
fit: z.enum(["contain", "cover", "fill", "inside", "outside"]).default("contain"),
withoutEnlargement: z.boolean().default(false),
percentage: z.number().positive().optional(),
});

View file

@ -1,8 +1,8 @@
import { flip, rotate } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { rotate, flip } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
angle: z.number().default(0),

View file

@ -1,6 +1,6 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@ -26,7 +26,7 @@ export function registerSmartCrop(app: FastifyInstance) {
.png()
.toBuffer();
const outputFilename = filename.replace(/\.[^.]+$/, "") + "_smartcrop.png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_smartcrop.png`;
return { buffer: result, filename: outputFilename, contentType: "image/png" };
},
});

View file

@ -1,9 +1,9 @@
import { z } from "zod";
import sharp from "sharp";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { basename, extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
const settingsSchema = z.object({
columns: z.number().min(1).max(10).default(2),
@ -14,99 +14,96 @@ const settingsSchema = z.object({
* Split an image into grid parts and return as ZIP.
*/
export function registerSplit(app: FastifyInstance) {
app.post(
"/api/v1/tools/split",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/split", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
const metadata = await sharp(fileBuffer).metadata();
const fullW = metadata.width ?? 0;
const fullH = metadata.height ?? 0;
const cellW = Math.floor(fullW / settings.columns);
const cellH = Math.floor(fullH / settings.rows);
const ext = extname(filename) || ".png";
const baseName = filename.replace(ext, "");
try {
const metadata = await sharp(fileBuffer).metadata();
const fullW = metadata.width ?? 0;
const fullH = metadata.height ?? 0;
const cellW = Math.floor(fullW / settings.columns);
const cellH = Math.floor(fullH / settings.rows);
const ext = extname(filename) || ".png";
const baseName = filename.replace(ext, "");
const jobId = randomUUID();
const jobId = randomUUID();
// Set up response headers for ZIP
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
// Set up response headers for ZIP
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="split-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
for (let row = 0; row < settings.rows; row++) {
for (let col = 0; col < settings.columns; col++) {
const left = col * cellW;
const top = row * cellH;
// Ensure we don't go out of bounds on the last row/col
const w = col === settings.columns - 1 ? fullW - left : cellW;
const h = row === settings.rows - 1 ? fullH - top : cellH;
for (let row = 0; row < settings.rows; row++) {
for (let col = 0; col < settings.columns; col++) {
const left = col * cellW;
const top = row * cellH;
// Ensure we don't go out of bounds on the last row/col
const w = col === settings.columns - 1 ? fullW - left : cellW;
const h = row === settings.rows - 1 ? fullH - top : cellH;
const partBuffer = await sharp(fileBuffer)
.extract({ left, top, width: w, height: h })
.toBuffer();
const partBuffer = await sharp(fileBuffer)
.extract({ left, top, width: w, height: h })
.toBuffer();
archive.append(partBuffer, {
name: `${baseName}_r${row + 1}_c${col + 1}${ext}`,
});
}
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Split failed",
details: err instanceof Error ? err.message : "Unknown error",
archive.append(partBuffer, {
name: `${baseName}_r${row + 1}_c${col + 1}${ext}`,
});
}
}
},
);
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Split failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
}
});
}

View file

@ -1,10 +1,10 @@
import { basename } from "node:path";
import { stripMetadata } from "@stirling-image/image-engine";
import exifReader from "exif-reader";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { stripMetadata } from "@stirling-image/image-engine";
import sharp from "sharp";
import exifReader from "exif-reader";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
const settingsSchema = z.object({
stripExif: z.boolean().default(false),
@ -116,7 +116,7 @@ function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
const major = iccBuffer[8];
const minor = (iccBuffer[9] >> 4) & 0xf;
if (major) info["Version"] = `${major}.${minor}`;
if (major) info.Version = `${major}.${minor}`;
// Extract description tag from ICC tag table
const tagCount = iccBuffer.readUInt32BE(128);
@ -132,8 +132,10 @@ function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
if (descType === "desc") {
const strLen = iccBuffer.readUInt32BE(dataOffset + 8);
if (strLen > 0 && strLen < 256) {
const desc = iccBuffer.subarray(dataOffset + 12, dataOffset + 12 + strLen - 1).toString("ascii");
info["Description"] = desc;
const desc = iccBuffer
.subarray(dataOffset + 12, dataOffset + 12 + strLen - 1)
.toString("ascii");
info.Description = desc;
}
} else if (descType === "mluc") {
const recCount = iccBuffer.readUInt32BE(dataOffset + 8);
@ -141,7 +143,10 @@ function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
const strOffset = iccBuffer.readUInt32BE(dataOffset + 20);
const strLength = iccBuffer.readUInt32BE(dataOffset + 16);
if (strOffset && strLength && dataOffset + strOffset + strLength <= iccBuffer.length) {
const raw = iccBuffer.subarray(dataOffset + strOffset, dataOffset + strOffset + strLength);
const raw = iccBuffer.subarray(
dataOffset + strOffset,
dataOffset + strOffset + strLength,
);
// ICC mluc strings are UTF-16BE: swap bytes for Node's utf16le decoder
const swapped = Buffer.alloc(raw.length);
for (let j = 0; j < raw.length - 1; j += 2) {
@ -149,7 +154,7 @@ function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
swapped[j + 1] = raw[j];
}
const desc = swapped.toString("utf16le");
info["Description"] = desc.replace(/\0/g, "");
info.Description = desc.replace(/\0/g, "");
}
}
}
@ -230,9 +235,9 @@ export function registerStripMetadata(app: FastifyInstance) {
gpsData[k] = sanitizeValue(v);
}
const coords = parseGpsCoordinates(parsed.GPSInfo as Record<string, unknown>);
if (coords.latitude !== null) gpsData["_latitude"] = coords.latitude;
if (coords.longitude !== null) gpsData["_longitude"] = coords.longitude;
if (coords.altitude !== null) gpsData["_altitude"] = coords.altitude;
if (coords.latitude !== null) gpsData._latitude = coords.latitude;
if (coords.longitude !== null) gpsData._longitude = coords.longitude;
if (coords.altitude !== null) gpsData._altitude = coords.altitude;
}
if (Object.keys(exifData).length > 0) result.exif = exifData;

View file

@ -1,15 +1,18 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
width: z.number().min(1).max(8192).default(1024),
height: z.number().min(1).max(8192).optional(),
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6,8}$/).default("#00000000"),
backgroundColor: z
.string()
.regex(/^#[0-9a-fA-F]{6,8}$/)
.default("#00000000"),
outputFormat: z.enum(["png", "jpg", "webp"]).default("png"),
});
@ -51,115 +54,111 @@ function sanitizeSvg(buffer: Buffer): Buffer {
* Custom route since input is SVG (not validated as image by magic bytes).
*/
export function registerSvgToRaster(app: FastifyInstance) {
app.post(
"/api/v1/tools/svg-to-raster",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/svg-to-raster", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "output").replace(/\.svg$/i, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "output").replace(/\.svg$/i, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No SVG file provided" });
}
// Sanitize SVG to prevent XXE, SSRF, and script injection
try {
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG",
});
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
let image = sharp(fileBuffer, { density: 300 }).resize(
settings.width,
settings.height ?? undefined,
{ fit: "inside" },
);
// Apply background if not transparent
if (settings.backgroundColor !== "#00000000") {
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } });
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No SVG file provided" });
let buffer: Buffer;
let ext: string;
let _contentType: string;
switch (settings.outputFormat) {
case "jpg":
buffer = await image.jpeg({ quality: 90 }).toBuffer();
ext = "jpg";
_contentType = "image/jpeg";
break;
case "webp":
buffer = await image.webp({ quality: 90 }).toBuffer();
ext = "webp";
_contentType = "image/webp";
break;
default:
buffer = await image.png().toBuffer();
ext = "png";
_contentType = "image/png";
break;
}
// Sanitize SVG to prevent XXE, SSRF, and script injection
try {
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG",
});
}
const outFilename = `${filename}.${ext}`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, buffer);
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
let image = sharp(fileBuffer, { density: 300 }).resize(
settings.width,
settings.height ?? undefined,
{ fit: "inside" },
);
// Apply background if not transparent
if (settings.backgroundColor !== "#00000000") {
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } });
}
let buffer: Buffer;
let ext: string;
let contentType: string;
switch (settings.outputFormat) {
case "jpg":
buffer = await image.jpeg({ quality: 90 }).toBuffer();
ext = "jpg";
contentType = "image/jpeg";
break;
case "webp":
buffer = await image.webp({ quality: 90 }).toBuffer();
ext = "webp";
contentType = "image/webp";
break;
case "png":
default:
buffer = await image.png().toBuffer();
ext = "png";
contentType = "image/png";
break;
}
const outFilename = `${filename}.${ext}`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize: fileBuffer.length,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "SVG conversion failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize: fileBuffer.length,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "SVG conversion failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,15 +1,21 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
text: z.string().min(1).max(500),
fontSize: z.number().min(8).max(200).default(48),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
position: z.enum(["top", "center", "bottom"]).default("bottom"),
backgroundBox: z.boolean().default(false),
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
backgroundColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
shadow: z.boolean().default(true),
});
@ -43,7 +49,6 @@ export function registerTextOverlay(app: FastifyInstance) {
case "center":
y = height / 2;
break;
case "bottom":
default:
y = height - pad;
break;

View file

@ -1,116 +1,112 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import { upscale } from "@stirling-image/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* AI image upscaling route.
* Uses Real-ESRGAN when available, falls back to Lanczos.
*/
export function registerUpscale(app: FastifyInstance) {
app.post(
"/api/v1/tools/upscale",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
app.post("/api/v1/tools/upscale", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const scale = Number(settings.scale) || 2;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await upscale(
fileBuffer,
join(workspacePath, "output"),
{ scale },
onProgress,
);
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const scale = Number(settings.scale) || 2;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
phase: "processing",
stage,
percent,
});
}
: undefined;
const result = await upscale(
fileBuffer,
join(workspacePath, "output"),
{ scale },
onProgress,
);
// Save output
const outputFilename =
filename.replace(/\.[^.]+$/, "") + `_${scale}x.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
width: result.width,
height: result.height,
method: result.method,
});
} catch (err) {
return reply.status(422).send({
error: "Upscaling failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
width: result.width,
height: result.height,
method: result.method,
});
} catch (err) {
return reply.status(422).send({
error: "Upscaling failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,10 +1,10 @@
import { z } from "zod";
import sharp from "sharp";
import potrace from "potrace";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import potrace from "potrace";
import sharp from "sharp";
import { z } from "zod";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@ -25,10 +25,7 @@ function traceImage(
});
}
function posterize(
buffer: Buffer,
options: { steps: number; threshold: number },
): Promise<string> {
function posterize(buffer: Buffer, options: { steps: number; threshold: number }): Promise<string> {
return new Promise((resolve, reject) => {
potrace.posterize(buffer, options, (err: Error | null, svg: string) => {
if (err) reject(err);
@ -38,95 +35,89 @@ function posterize(
}
export function registerVectorize(app: FastifyInstance) {
app.post(
"/api/v1/tools/vectorize",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/vectorize", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "output").replace(/\.[^.]+$/, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "output").replace(/\.[^.]+$/, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Convert to BMP-compatible format for potrace (PNG)
const pngBuffer = await sharp(fileBuffer).grayscale().png().toBuffer();
const turdSize = settings.detail === "low" ? 10 : settings.detail === "high" ? 1 : 4;
let svg: string;
if (settings.colorMode === "color") {
// Color mode: posterize
svg = await posterize(pngBuffer, {
steps: settings.detail === "low" ? 3 : settings.detail === "high" ? 8 : 5,
threshold: settings.threshold,
});
} else {
// B&W mode: simple trace
svg = await traceImage(pngBuffer, {
threshold: settings.threshold,
turdSize,
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const svgBuffer = Buffer.from(svg, "utf-8");
const outFilename = `${filename}.svg`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, svgBuffer);
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Convert to BMP-compatible format for potrace (PNG)
const pngBuffer = await sharp(fileBuffer)
.grayscale()
.png()
.toBuffer();
const turdSize = settings.detail === "low" ? 10 : settings.detail === "high" ? 1 : 4;
let svg: string;
if (settings.colorMode === "color") {
// Color mode: posterize
svg = await posterize(pngBuffer, {
steps: settings.detail === "low" ? 3 : settings.detail === "high" ? 8 : 5,
threshold: settings.threshold,
});
} else {
// B&W mode: simple trace
svg = await traceImage(pngBuffer, {
threshold: settings.threshold,
turdSize,
});
}
const svgBuffer = Buffer.from(svg, "utf-8");
const outFilename = `${filename}.svg`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, svgBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize: fileBuffer.length,
processedSize: svgBuffer.length,
svgPreview: svg.length < 50000 ? svg : undefined,
});
} catch (err) {
return reply.status(422).send({
error: "Vectorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize: fileBuffer.length,
processedSize: svgBuffer.length,
svgPreview: svg.length < 50000 ? svg : undefined,
});
} catch (err) {
return reply.status(422).send({
error: "Vectorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
}

View file

@ -1,6 +1,6 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
const settingsSchema = z.object({
position: z
@ -12,156 +12,150 @@ const settingsSchema = z.object({
export function registerWatermarkImage(app: FastifyInstance) {
// Custom route since we need two file uploads
app.post(
"/api/v1/tools/watermark-image",
async (request, reply) => {
let mainBuffer: Buffer | null = null;
let watermarkBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
app.post("/api/v1/tools/watermark-image", async (request, reply) => {
let mainBuffer: Buffer | null = null;
let watermarkBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "watermark") {
watermarkBuffer = buf;
} else {
mainBuffer = buf;
filename = part.filename ?? "image";
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "watermark") {
watermarkBuffer = buf;
} else {
mainBuffer = buf;
filename = part.filename ?? "image";
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!mainBuffer || mainBuffer.length === 0) {
return reply.status(400).send({ error: "No main image file provided" });
if (!mainBuffer || mainBuffer.length === 0) {
return reply.status(400).send({ error: "No main image file provided" });
}
// Parse settings
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// Parse settings
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// If no watermark uploaded, just return the image
if (!watermarkBuffer || watermarkBuffer.length === 0) {
return reply.status(400).send({ error: "No watermark image provided" });
}
// If no watermark uploaded, just return the image
if (!watermarkBuffer || watermarkBuffer.length === 0) {
return reply.status(400).send({ error: "No watermark image provided" });
}
try {
const mainImage = sharp(mainBuffer);
const mainMeta = await mainImage.metadata();
const mainW = mainMeta.width ?? 800;
const mainH = mainMeta.height ?? 600;
try {
const mainImage = sharp(mainBuffer);
const mainMeta = await mainImage.metadata();
const mainW = mainMeta.width ?? 800;
const mainH = mainMeta.height ?? 600;
// Scale watermark
const wmWidth = Math.round((mainW * settings.scale) / 100);
let wmImage = sharp(watermarkBuffer).resize({ width: wmWidth });
// Scale watermark
const wmWidth = Math.round((mainW * settings.scale) / 100);
let wmImage = sharp(watermarkBuffer).resize({ width: wmWidth });
// Apply opacity via ensureAlpha + modulate
if (settings.opacity < 100) {
const wmBuf = await wmImage.ensureAlpha().toBuffer();
const wmMeta = await sharp(wmBuf).metadata();
const wmW = wmMeta.width ?? wmWidth;
const wmH = wmMeta.height ?? wmWidth;
// Create an opacity mask
const opacityOverlay = await sharp({
create: {
width: wmW,
height: wmH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
},
})
.png()
.toBuffer();
wmImage = sharp(wmBuf).composite([
{ input: opacityOverlay, blend: "dest-in" },
]);
}
const wmBuffer = await wmImage.toBuffer();
const wmMeta = await sharp(wmBuffer).metadata();
// Apply opacity via ensureAlpha + modulate
if (settings.opacity < 100) {
const wmBuf = await wmImage.ensureAlpha().toBuffer();
const wmMeta = await sharp(wmBuf).metadata();
const wmW = wmMeta.width ?? wmWidth;
const wmH = wmMeta.height ?? 0;
// Calculate position
const pad = 20;
let top = 0;
let left = 0;
switch (settings.position) {
case "top-left":
top = pad;
left = pad;
break;
case "top-right":
top = pad;
left = Math.max(0, mainW - wmW - pad);
break;
case "bottom-left":
top = Math.max(0, mainH - wmH - pad);
left = pad;
break;
case "bottom-right":
top = Math.max(0, mainH - wmH - pad);
left = Math.max(0, mainW - wmW - pad);
break;
case "center":
default:
top = Math.max(0, Math.round((mainH - wmH) / 2));
left = Math.max(0, Math.round((mainW - wmW) / 2));
break;
}
const result = await sharp(mainBuffer)
.composite([{ input: wmBuffer, top, left }])
const wmH = wmMeta.height ?? wmWidth;
// Create an opacity mask
const opacityOverlay = await sharp({
create: {
width: wmW,
height: wmH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: settings.opacity / 100 },
},
})
.png()
.toBuffer();
// Use tool-factory's workspace pattern
const { randomUUID } = await import("node:crypto");
const { writeFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const { createWorkspace } = await import("../../lib/workspace.js");
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
originalSize: mainBuffer.length,
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
wmImage = sharp(wmBuf).composite([{ input: opacityOverlay, blend: "dest-in" }]);
}
},
);
const wmBuffer = await wmImage.toBuffer();
const wmMeta = await sharp(wmBuffer).metadata();
const wmW = wmMeta.width ?? wmWidth;
const wmH = wmMeta.height ?? 0;
// Calculate position
const pad = 20;
let top = 0;
let left = 0;
switch (settings.position) {
case "top-left":
top = pad;
left = pad;
break;
case "top-right":
top = pad;
left = Math.max(0, mainW - wmW - pad);
break;
case "bottom-left":
top = Math.max(0, mainH - wmH - pad);
left = pad;
break;
case "bottom-right":
top = Math.max(0, mainH - wmH - pad);
left = Math.max(0, mainW - wmW - pad);
break;
default:
top = Math.max(0, Math.round((mainH - wmH) / 2));
left = Math.max(0, Math.round((mainW - wmW) / 2));
break;
}
const result = await sharp(mainBuffer)
.composite([{ input: wmBuffer, top, left }])
.toBuffer();
// Use tool-factory's workspace pattern
const { randomUUID } = await import("node:crypto");
const { writeFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const { createWorkspace } = await import("../../lib/workspace.js");
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
originalSize: mainBuffer.length,
processedSize: result.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Image processing failed",
});
}
});
}

View file

@ -1,12 +1,15 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
text: z.string().min(1).max(500),
fontSize: z.number().min(8).max(200).default(48),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#000000"),
opacity: z.number().min(0).max(100).default(50),
position: z
.enum(["center", "top-left", "top-right", "bottom-left", "bottom-right", "tiled"])
@ -86,7 +89,6 @@ export function registerWatermarkText(app: FastifyInstance) {
y = height - pad;
anchor = "end";
break;
case "center":
default:
x = width / 2;
y = height / 2;

View file

@ -1,59 +1,55 @@
import { defineConfig } from 'vitepress'
import { defineConfig } from "vitepress";
export default defineConfig({
title: "Stirling Image",
description: "Documentation for Stirling Image, a self-hosted image processing suite.",
base: '/Stirling-Image/',
srcDir: '.',
outDir: './.vitepress/dist',
base: "/Stirling-Image/",
srcDir: ".",
outDir: "./.vitepress/dist",
head: [
['meta', { name: 'theme-color', content: '#3b82f6' }],
],
head: [["meta", { name: "theme-color", content: "#3b82f6" }]],
themeConfig: {
nav: [
{ text: 'Home', link: '/' },
{ text: 'Guide', link: '/guide/getting-started' },
{ text: 'API Reference', link: '/api/rest' }
{ text: "Home", link: "/" },
{ text: "Guide", link: "/guide/getting-started" },
{ text: "API Reference", link: "/api/rest" },
],
sidebar: [
{
text: 'Guide',
text: "Guide",
items: [
{ text: 'Getting started', link: '/guide/getting-started' },
{ text: 'Architecture', link: '/guide/architecture' },
{ text: 'Configuration', link: '/guide/configuration' },
{ text: 'Database', link: '/guide/database' },
{ text: 'Deployment', link: '/guide/deployment' }
]
{ text: "Getting started", link: "/guide/getting-started" },
{ text: "Architecture", link: "/guide/architecture" },
{ text: "Configuration", link: "/guide/configuration" },
{ text: "Database", link: "/guide/database" },
{ text: "Deployment", link: "/guide/deployment" },
],
},
{
text: 'API reference',
text: "API reference",
items: [
{ text: 'REST API', link: '/api/rest' },
{ text: 'Image engine', link: '/api/image-engine' },
{ text: 'AI engine', link: '/api/ai' }
]
}
{ text: "REST API", link: "/api/rest" },
{ text: "Image engine", link: "/api/image-engine" },
{ text: "AI engine", link: "/api/ai" },
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/siddharthksah/Stirling-Image' }
],
socialLinks: [{ icon: "github", link: "https://github.com/siddharthksah/Stirling-Image" }],
search: {
provider: 'local'
provider: "local",
},
footer: {
message: 'Released under the MIT License.',
message: "Released under the MIT License.",
},
editLink: {
pattern: 'https://github.com/siddharthksah/Stirling-Image/edit/main/apps/docs/:path',
text: 'Edit this page on GitHub'
}
}
})
pattern: "https://github.com/siddharthksah/Stirling-Image/edit/main/apps/docs/:path",
text: "Edit this page on GitHub",
},
},
});

View file

@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},

View file

@ -1,4 +1,4 @@
import { useRef, useState, useCallback, useEffect, type PointerEvent } from "react";
import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
interface BeforeAfterSliderProps {
/** URL or data URL of original image. */
@ -34,17 +34,14 @@ export function BeforeAfterSlider({
const [position, setPosition] = useState(50); // percentage 0-100
const [isDragging, setIsDragging] = useState(false);
const updatePosition = useCallback(
(clientX: number) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100));
setPosition(pct);
},
[],
);
const updatePosition = useCallback((clientX: number) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100));
setPosition(pct);
}, []);
const handlePointerDown = useCallback(
(e: PointerEvent) => {
@ -95,12 +92,7 @@ export function BeforeAfterSlider({
onPointerCancel={handlePointerUp}
>
{/* Before image (full width, bottom layer) */}
<img
src={beforeSrc}
alt="Original"
className="block w-full h-auto"
draggable={false}
/>
<img src={beforeSrc} alt="Original" className="block w-full h-auto" draggable={false} />
{/* After image (clipped, top layer) */}
<div
@ -124,13 +116,7 @@ export function BeforeAfterSlider({
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-primary">
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
@ -170,9 +156,7 @@ export function BeforeAfterSlider({
<span className="ml-1">({savingsPercent}% smaller)</span>
)}
{savingsPercent !== null && Number(savingsPercent) < 0 && (
<span className="ml-1">
({Math.abs(Number(savingsPercent))}% larger)
</span>
<span className="ml-1">({Math.abs(Number(savingsPercent))}% larger)</span>
)}
</span>
</div>

View file

@ -1,5 +1,5 @@
import { useCallback, useState, type DragEvent } from "react";
import { Upload, FileImage } from "lucide-react";
import { FileImage, Upload } from "lucide-react";
import { type DragEvent, useCallback, useState } from "react";
import { cn } from "@/lib/utils";
interface DropzoneProps {
@ -28,7 +28,7 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) onFiles?.(files);
},
[onFiles]
[onFiles],
);
const handleClick = () => {
@ -56,7 +56,7 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors cursor-pointer min-h-[400px] mx-auto max-w-2xl w-full",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50"
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
)}
>
<div className="flex flex-col items-center gap-4 p-8">
@ -67,9 +67,7 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
<Upload className="h-4 w-4" />
Upload from computer
</button>
<p className="text-sm text-muted-foreground">
Drop files here or click the upload button
</p>
<p className="text-sm text-muted-foreground">Drop files here or click the upload button</p>
{/* Show file count badge and list when multiple files are dropped */}
{hasMultipleFiles && (

View file

@ -1,5 +1,5 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { ZoomIn, ZoomOut, Maximize, Minimize2 } from "lucide-react";
import { Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { formatFileSize } from "@/lib/download";
interface ImageViewerProps {
@ -14,7 +14,14 @@ interface ImageViewerProps {
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200, 300];
const DEFAULT_ZOOM = 100;
export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssFlipV }: ImageViewerProps) {
export function ImageViewer({
src,
filename,
fileSize,
cssRotate,
cssFlipH,
cssFlipV,
}: ImageViewerProps) {
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
@ -63,7 +70,7 @@ export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssF
setFitMode("fit");
setNaturalWidth(null);
setNaturalHeight(null);
}, [src]);
}, []);
const previewTransform = [
cssRotate ? `rotate(${cssRotate}deg)` : "",

View file

@ -5,11 +5,7 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
* Must be rendered inside a <BrowserRouter> so that
* useNavigate() works inside the hook.
*/
export function KeyboardShortcutProvider({
children,
}: {
children: React.ReactNode;
}) {
export function KeyboardShortcutProvider({ children }: { children: React.ReactNode }) {
useKeyboardShortcuts();
return <>{children}</>;
}

View file

@ -1,7 +1,7 @@
import { useCallback } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ImageViewer } from "@/components/common/image-viewer";
import { useCallback } from "react";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { ImageViewer } from "@/components/common/image-viewer";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { useFileStore } from "@/stores/file-store";
@ -14,30 +14,59 @@ export function MultiImageViewer() {
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") { e.preventDefault(); navigatePrev(); }
else if (e.key === "ArrowRight") { e.preventDefault(); navigateNext(); }
}, [navigateNext, navigatePrev]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
navigatePrev();
} else if (e.key === "ArrowRight") {
e.preventDefault();
navigateNext();
}
},
[navigateNext, navigatePrev],
);
const hasProcessed = !!currentEntry.processedUrl;
return (
<div className="flex flex-col w-full h-full min-h-0" onKeyDown={hasMultiple ? handleKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
<div
className="flex flex-col w-full h-full min-h-0"
onKeyDown={hasMultiple ? handleKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
>
<div className="flex-1 relative flex items-center justify-center min-h-0">
{hasMultiple && hasPrev && (
<button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image">
<button
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
<div className="w-full h-full min-h-0">
{hasProcessed ? (
<BeforeAfterSlider beforeSrc={currentEntry.blobUrl} afterSrc={currentEntry.processedUrl!} beforeSize={currentEntry.originalSize} afterSize={currentEntry.processedSize ?? undefined} />
<BeforeAfterSlider
beforeSrc={currentEntry.blobUrl}
afterSrc={currentEntry.processedUrl!}
beforeSize={currentEntry.originalSize}
afterSize={currentEntry.processedSize ?? undefined}
/>
) : (
<ImageViewer src={currentEntry.blobUrl} filename={currentEntry.file.name} fileSize={currentEntry.file.size} />
<ImageViewer
src={currentEntry.blobUrl}
filename={currentEntry.file.name}
fileSize={currentEntry.file.size}
/>
)}
</div>
{hasMultiple && hasNext && (
<button onClick={navigateNext} className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Next image">
<button
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
>
<ChevronRight className="h-4 w-4" />
</button>
)}

View file

@ -1,4 +1,4 @@
import { Upload, Loader2 } from "lucide-react";
import { Loader2, Upload } from "lucide-react";
interface ProgressCardProps {
active: boolean;
@ -9,14 +9,7 @@ interface ProgressCardProps {
elapsed: number;
}
export function ProgressCard({
active,
phase,
label,
stage,
percent,
elapsed,
}: ProgressCardProps) {
export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ProgressCardProps) {
if (!active) return null;
const icon =
@ -35,12 +28,8 @@ export function ProgressCard({
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{label}
</div>
<div className="text-[11px] text-muted-foreground truncate">
{sublabel}
</div>
<div className="text-sm font-medium text-foreground truncate">{label}</div>
<div className="text-[11px] text-muted-foreground truncate">{sublabel}</div>
</div>
<span className="text-sm font-semibold text-primary font-mono tabular-nums">
{Math.round(percent)}%

View file

@ -1,15 +1,9 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { TOOLS } from "@stirling-image/shared";
import {
Download,
Undo2,
ChevronDown,
ChevronRight,
ArrowRight,
} from "lucide-react";
import * as icons from "lucide-react";
import { triggerDownload, formatFileSize } from "@/lib/download";
import { ArrowRight, ChevronDown, ChevronRight, Download, Undo2 } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { formatFileSize, triggerDownload } from "@/lib/download";
import { getSuggestedTools } from "@/lib/suggested-tools";
interface ReviewPanelProps {
@ -35,18 +29,13 @@ export function ReviewPanel({
const [isSuggestionsExpanded, setIsSuggestionsExpanded] = useState(true);
const navigate = useNavigate();
const suggestedToolIds = useMemo(
() => getSuggestedTools(currentToolId),
[currentToolId],
);
const suggestedToolIds = useMemo(() => getSuggestedTools(currentToolId), [currentToolId]);
const suggestedTools = useMemo(
() =>
suggestedToolIds
.map((id) => TOOLS.find((t) => t.id === id))
.filter(
(t): t is (typeof TOOLS)[number] => t !== undefined,
),
.filter((t): t is (typeof TOOLS)[number] => t !== undefined),
[suggestedToolIds],
);
@ -69,11 +58,7 @@ export function ReviewPanel({
className="flex items-center justify-between w-full text-sm font-medium text-muted-foreground hover:text-foreground"
>
<span>Review</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{isExpanded && (
@ -120,9 +105,7 @@ export function ReviewPanel({
<div className="space-y-2">
<div className="border-t border-border pt-2" />
<button
onClick={() =>
setIsSuggestionsExpanded(!isSuggestionsExpanded)
}
onClick={() => setIsSuggestionsExpanded(!isSuggestionsExpanded)}
className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground"
>
<span>Continue editing</span>

View file

@ -6,11 +6,7 @@ interface SearchBarProps {
placeholder?: string;
}
export function SearchBar({
value,
onChange,
placeholder = "Search tools...",
}: SearchBarProps) {
export function SearchBar({ value, onChange, placeholder = "Search tools..." }: SearchBarProps) {
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />

View file

@ -1,5 +1,5 @@
import { useRef, useEffect } from "react";
import { CheckCircle2, XCircle } from "lucide-react";
import { useEffect, useRef } from "react";
import type { FileEntry } from "@/stores/file-store";
interface ThumbnailStripProps {
@ -17,12 +17,15 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
inline: "nearest",
behavior: "smooth",
});
}, [selectedIndex]);
}, []);
if (entries.length <= 1) return null;
return (
<div className="flex gap-1.5 px-3 py-2 overflow-x-auto border-t border-border bg-muted/30" style={{ scrollBehavior: "smooth" }}>
<div
className="flex gap-1.5 px-3 py-2 overflow-x-auto border-t border-border bg-muted/30"
style={{ scrollBehavior: "smooth" }}
>
{entries.map((entry, i) => {
const isSelected = i === selectedIndex;
const isCompleted = entry.status === "completed";
@ -33,12 +36,19 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect(i)}
className={`relative shrink-0 rounded overflow-hidden transition-all ${
isSelected ? "outline outline-2 outline-primary outline-offset-1" : "hover:outline hover:outline-1 hover:outline-border"
isSelected
? "outline outline-2 outline-primary outline-offset-1"
: "hover:outline hover:outline-1 hover:outline-border"
}`}
style={{ width: 52, height: 38 }}
title={entry.file.name}
>
<img src={entry.processedUrl ?? entry.blobUrl} alt={entry.file.name} className="w-full h-full object-cover" draggable={false} />
<img
src={entry.processedUrl ?? entry.blobUrl}
alt={entry.file.name}
className="w-full h-full object-cover"
draggable={false}
/>
{isCompleted && (
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
<CheckCircle2 className="h-2.5 w-2.5 text-white" />

View file

@ -1,7 +1,7 @@
import { Link } from "react-router-dom";
import { Star, FileImage } from "lucide-react";
import * as icons from "lucide-react";
import type { Tool } from "@stirling-image/shared";
import * as icons from "lucide-react";
import { FileImage, Star } from "lucide-react";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
interface ToolCardProps {
@ -9,10 +9,7 @@ interface 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;
return (
@ -28,14 +25,14 @@ export function ToolCard({ tool }: ToolCardProps) {
className={cn(
"flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors",
"hover:bg-muted",
tool.disabled && "opacity-50 pointer-events-none"
tool.disabled && "opacity-50 pointer-events-none",
)}
>
<IconComponent className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
{tool.alpha && (
{tool.experimental && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
Alpha
Experimental
</span>
)}
</Link>

View file

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { X, Keyboard, BookOpen, Github, ExternalLink } from "lucide-react";
import { APP_VERSION } from "@stirling-image/shared";
import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react";
import { useEffect } from "react";
import { formatShortcut } from "@/hooks/use-keyboard-shortcuts";
interface HelpDialogProps {
@ -36,10 +36,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
@ -62,10 +59,9 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
<h3 className="text-sm font-semibold">Getting Started</h3>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Select a tool from the sidebar or search for one with{" "}
<Kbd keys="mod+k" />. Upload an image by dragging it onto the
page or clicking the upload area. Adjust settings and download
your result.
Select a tool from the sidebar or search for one with <Kbd keys="mod+k" />. Upload an
image by dragging it onto the page or clicking the upload area. Adjust settings and
download your result.
</p>
</section>
@ -80,14 +76,10 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
<div
key={s.keys}
className={`flex items-center justify-between px-3 py-2 text-sm ${
i !== SHORTCUTS.length - 1
? "border-b border-border"
: ""
i !== SHORTCUTS.length - 1 ? "border-b border-border" : ""
}`}
>
<span className="text-muted-foreground">
{s.description}
</span>
<span className="text-muted-foreground">{s.description}</span>
<Kbd keys={s.keys} />
</div>
))}

View file

@ -1,4 +1,4 @@
import { Moon, Sun, Globe } from "lucide-react";
import { Globe, Moon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
export function Footer() {
@ -11,11 +11,7 @@ export function Footer() {
className="p-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors"
title="Toggle Theme"
>
{resolvedTheme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors text-sm"

View file

@ -1,5 +1,5 @@
import { useState, useMemo } from "react";
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
import { useMemo, useState } from "react";
import { SearchBar } from "../common/search-bar";
import { ToolCard } from "../common/tool-card";
@ -10,9 +10,7 @@ export function ToolPanel() {
if (!search) return TOOLS;
const q = search.toLowerCase();
return TOOLS.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q)
(t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q),
);
}, [search]);
@ -32,24 +30,20 @@ export function ToolPanel() {
<SearchBar value={search} onChange={setSearch} />
</div>
<div className="px-3 pb-4 flex-1">
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map(
(category) => (
<div key={category.id} className="mb-4">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
{category.name}
</h3>
<div className="space-y-0.5">
{groupedTools.get(category.id)!.map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
<div key={category.id} className="mb-4">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
{category.name}
</h3>
<div className="space-y-0.5">
{groupedTools.get(category.id)?.map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
)
)}
</div>
))}
{filteredTools.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No tools found
</p>
<p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
)}
</div>
</div>

View file

@ -1,24 +1,28 @@
import { useState, useCallback, useEffect } from "react";
import { APP_VERSION } from "@stirling-image/shared";
import {
X,
Settings,
Shield,
Key,
Info,
Check,
Copy,
Eye,
EyeOff,
Copy,
Check,
Info,
Key,
Loader2,
LogOut,
Monitor,
Users,
MoreVertical,
Pencil,
RotateCcw,
Search,
Settings,
Shield,
Trash2,
Plus,
Loader2,
UserPlus,
Users,
X,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { apiDelete, apiGet, apiPost, apiPut, clearToken } from "@/lib/api";
import { cn } from "@/lib/utils";
import { apiGet, apiPost, apiPut, apiDelete, clearToken } from "@/lib/api";
import { APP_VERSION } from "@stirling-image/shared";
interface SettingsDialogProps {
open: boolean;
@ -60,10 +64,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* Dialog */}
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85vh] flex overflow-hidden">
@ -80,7 +81,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
"flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors",
section === item.id
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-4 w-4" />
@ -126,9 +127,10 @@ interface ApiKeyEntry {
}
interface UserEntry {
id: number;
id: string;
username: string;
role: string;
team: string;
createdAt: string;
}
@ -165,16 +167,18 @@ function GeneralSection() {
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-foreground">General</h3>
<p className="text-sm text-muted-foreground mt-1">
User preferences and display settings.
</p>
<p className="text-sm text-muted-foreground mt-1">User preferences and display settings.</p>
</div>
{/* User info */}
<div className="flex items-center justify-between p-4 rounded-lg border border-border bg-muted/20">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-semibold">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : username.charAt(0).toUpperCase()}
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
username.charAt(0).toUpperCase()
)}
</div>
<div>
<p className="font-medium text-foreground">{loading ? "Loading..." : username}</p>
@ -224,17 +228,15 @@ function SystemSection() {
fileUploadLimitMb: "100",
defaultTheme: "system",
defaultLocale: "en",
loginAttemptLimit: "5",
});
})
.finally(() => setLoading(false));
}, []);
const updateSetting = useCallback(
(key: string, value: string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
},
[]
);
const updateSetting = useCallback((key: string, value: string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
@ -262,9 +264,7 @@ function SystemSection() {
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-foreground">System Settings</h3>
<p className="text-sm text-muted-foreground mt-1">
Server-side configuration and limits.
</p>
<p className="text-sm text-muted-foreground mt-1">Server-side configuration and limits.</p>
</div>
<SettingRow label="App Name" description="Display name for the application">
@ -298,7 +298,7 @@ function SystemSection() {
</select>
</SettingRow>
<SettingRow label="Default Locale" description="Language for the interface">
<SettingRow label="Language" description="Language for the interface">
<select
value={settings.defaultLocale || "en"}
onChange={(e) => updateSetting("defaultLocale", e.target.value)}
@ -313,6 +313,20 @@ function SystemSection() {
</select>
</SettingRow>
<SettingRow
label="Login Attempt Limit"
description="Max failed login attempts per minute before lockout"
>
<input
type="number"
value={settings.loginAttemptLimit || "5"}
onChange={(e) => updateSetting("loginAttemptLimit", e.target.value)}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={1}
max={100}
/>
</SettingRow>
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
@ -323,7 +337,14 @@ function SystemSection() {
Save Settings
</button>
{saveMsg && (
<span className={cn("text-sm", saveMsg.includes("Failed") ? "text-destructive" : "text-green-600 dark:text-green-400")}>
<span
className={cn(
"text-sm",
saveMsg.includes("Failed")
? "text-destructive"
: "text-green-600 dark:text-green-400",
)}
>
{saveMsg}
</span>
)}
@ -365,21 +386,22 @@ function SecuritySection() {
setConfirmPassword("");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to change password";
setMessage({ type: "error", text: msg.includes("401") ? "Current password is incorrect" : msg });
setMessage({
type: "error",
text: msg.includes("401") ? "Current password is incorrect" : msg,
});
} finally {
setSubmitting(false);
}
},
[currentPassword, newPassword, confirmPassword]
[currentPassword, newPassword, confirmPassword],
);
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-foreground">Security</h3>
<p className="text-sm text-muted-foreground mt-1">
Password and authentication settings.
</p>
<p className="text-sm text-muted-foreground mt-1">Password and authentication settings.</p>
</div>
<form onSubmit={handleChangePassword} className="space-y-4">
@ -435,7 +457,9 @@ function SecuritySection() {
<p
className={cn(
"text-sm",
message.type === "error" ? "text-destructive" : "text-green-600 dark:text-green-400"
message.type === "error"
? "text-destructive"
: "text-green-600 dark:text-green-400",
)}
>
{message.text}
@ -454,9 +478,9 @@ function SecuritySection() {
</form>
<div className="border-t border-border pt-4">
<SettingRow label="Login Attempt Limit" description="Max failed attempts before lockout">
<span className="text-sm font-mono text-muted-foreground">5 attempts</span>
</SettingRow>
<p className="text-sm text-muted-foreground">
Login attempt limits can be configured in System Settings.
</p>
</div>
</div>
);
@ -466,18 +490,31 @@ function SecuritySection() {
function PeopleSection() {
const [users, setUsers] = useState<UserEntry[]>([]);
const [maxUsers, setMaxUsers] = useState(5);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [showAddForm, setShowAddForm] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newRole, setNewRole] = useState("user");
const [newTeam, setNewTeam] = useState("Default");
const [addError, setAddError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<UserEntry | null>(null);
const [editRole, setEditRole] = useState("");
const [editTeam, setEditTeam] = useState("");
const [resetPasswordUser, setResetPasswordUser] = useState<UserEntry | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [actionMsg, setActionMsg] = useState<{ type: "success" | "error"; text: string } | null>(
null,
);
const loadUsers = useCallback(async () => {
try {
const data = await apiGet<{ users: UserEntry[] }>("/auth/users");
const data = await apiGet<{ users: UserEntry[]; maxUsers: number }>("/auth/users");
setUsers(data.users);
setMaxUsers(data.maxUsers);
} catch {
setUsers([]);
} finally {
@ -489,6 +526,20 @@ function PeopleSection() {
loadUsers();
}, [loadUsers]);
// Close dropdown when clicking outside
useEffect(() => {
if (!openMenuId) return;
const handler = () => setOpenMenuId(null);
window.addEventListener("click", handler);
return () => window.removeEventListener("click", handler);
}, [openMenuId]);
const filteredUsers = users.filter((u) =>
u.username.toLowerCase().includes(search.toLowerCase()),
);
const atLimit = users.length >= maxUsers;
const handleAddUser = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
@ -499,32 +550,84 @@ function PeopleSection() {
username: newUsername,
password: newPassword,
role: newRole,
team: newTeam,
});
setNewUsername("");
setNewPassword("");
setNewRole("user");
setNewTeam("Default");
setShowAddForm(false);
setActionMsg({ type: "success", text: "User created successfully" });
await loadUsers();
} catch (err) {
setAddError(err instanceof Error ? err.message : "Failed to create user");
const msg = err instanceof Error ? err.message : "Failed to create user";
setAddError(msg.includes("403") ? `User limit reached (${maxUsers} max)` : msg);
} finally {
setAdding(false);
setTimeout(() => setActionMsg(null), 3000);
}
},
[newUsername, newPassword, newRole, loadUsers]
[newUsername, newPassword, newRole, newTeam, maxUsers, loadUsers],
);
const handleDeleteUser = useCallback(
async (id: number, username: string) => {
async (id: string, username: string) => {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try {
await apiDelete(`/auth/users/${id}`);
setActionMsg({ type: "success", text: `User "${username}" deleted` });
await loadUsers();
} catch {
// Silently fail - user likely lacks permission
setActionMsg({ type: "error", text: "Failed to delete user" });
}
setOpenMenuId(null);
setTimeout(() => setActionMsg(null), 3000);
},
[loadUsers]
[loadUsers],
);
const handleUpdateUser = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!editingUser) return;
try {
await apiPut(`/auth/users/${editingUser.id}`, {
role: editRole,
team: editTeam,
});
setEditingUser(null);
setActionMsg({ type: "success", text: "User updated" });
await loadUsers();
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to update user";
setActionMsg({
type: "error",
text: msg.includes("400") ? "Cannot remove your own admin role" : msg,
});
}
setTimeout(() => setActionMsg(null), 3000);
},
[editingUser, editRole, editTeam, loadUsers],
);
const handleResetPassword = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!resetPasswordUser) return;
try {
await apiPost(`/auth/users/${resetPasswordUser.id}/reset-password`, {
newPassword: resetPassword,
});
setResetPasswordUser(null);
setResetPassword("");
setActionMsg({ type: "success", text: "Password reset successfully" });
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to reset password";
setActionMsg({ type: "error", text: msg });
}
setTimeout(() => setActionMsg(null), 3000);
},
[resetPasswordUser, resetPassword],
);
if (loading) {
@ -536,35 +639,80 @@ function PeopleSection() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-foreground">People</h3>
<p className="text-sm text-muted-foreground mt-1">
Manage users and their roles.
</p>
<div className="space-y-5">
{/* Header */}
<div>
<h3 className="text-lg font-semibold text-foreground">People</h3>
<p className="text-sm text-muted-foreground mt-1">
Manage workspace members and their permissions
</p>
</div>
{/* User count */}
<p className="text-sm text-muted-foreground">
{users.length} / {maxUsers} users
</p>
{/* Action message */}
{actionMsg && (
<div
className={cn(
"text-sm px-3 py-2 rounded-lg",
actionMsg.type === "error"
? "bg-destructive/10 text-destructive"
: "bg-green-500/10 text-green-600 dark:text-green-400",
)}
>
{actionMsg.text}
</div>
)}
{/* Search + Add Members */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search members..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
/>
</div>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={() => {
setShowAddForm(!showAddForm);
setAddError(null);
}}
disabled={atLimit && !showAddForm}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors",
atLimit && !showAddForm
? "bg-muted text-muted-foreground cursor-not-allowed"
: "bg-primary text-primary-foreground hover:bg-primary/90",
)}
title={atLimit ? `User limit reached (${maxUsers} max)` : "Add a new member"}
>
<Plus className="h-3.5 w-3.5" />
Add User
<UserPlus className="h-4 w-4" />
Add Members
</button>
</div>
{/* Add user form */}
{showAddForm && (
<form onSubmit={handleAddUser} className="p-4 rounded-lg border border-border bg-muted/20 space-y-3">
<h4 className="text-sm font-medium text-foreground">New User</h4>
<div className="flex flex-wrap gap-3">
<form
onSubmit={handleAddUser}
className="p-4 rounded-lg border border-border bg-muted/20 space-y-3"
>
<h4 className="text-sm font-medium text-foreground">New Member</h4>
<div className="grid grid-cols-2 gap-3">
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="Username"
required
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-40"
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
/>
<input
type="password"
@ -572,8 +720,8 @@ function PeopleSection() {
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Password"
required
minLength={4}
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-40"
minLength={8}
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
/>
<select
value={newRole}
@ -583,47 +731,216 @@ function PeopleSection() {
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<input
type="text"
value={newTeam}
onChange={(e) => setNewTeam(e.target.value)}
placeholder="Team"
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={adding}
disabled={adding || atLimit}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{adding && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create
</button>
<button
type="button"
onClick={() => setShowAddForm(false)}
className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
</div>
{addError && (
<p className="text-sm text-destructive">{addError}</p>
)}
{addError && <p className="text-sm text-destructive">{addError}</p>}
</form>
)}
{/* User list */}
<div className="space-y-1">
{users.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No users found.</p>
{/* Edit user modal */}
{editingUser && (
<form
onSubmit={handleUpdateUser}
className="p-4 rounded-lg border border-primary/30 bg-primary/5 space-y-3"
>
<h4 className="text-sm font-medium text-foreground">Edit {editingUser.username}</h4>
<div className="flex flex-wrap gap-3">
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<input
type="text"
value={editTeam}
onChange={(e) => setEditTeam(e.target.value)}
placeholder="Team"
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-40"
/>
<button
type="submit"
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Save
</button>
<button
type="button"
onClick={() => setEditingUser(null)}
className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
</div>
</form>
)}
{/* Reset password modal */}
{resetPasswordUser && (
<form
onSubmit={handleResetPassword}
className="p-4 rounded-lg border border-orange-500/30 bg-orange-500/5 space-y-3"
>
<h4 className="text-sm font-medium text-foreground">
Reset password for {resetPasswordUser.username}
</h4>
<div className="flex flex-wrap gap-3">
<input
type="password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
placeholder="New password (min 8 chars)"
required
minLength={8}
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-60"
/>
<button
type="submit"
className="px-4 py-2 rounded-lg bg-orange-500 text-white text-sm font-medium hover:bg-orange-600 transition-colors"
>
Reset Password
</button>
<button
type="button"
onClick={() => {
setResetPasswordUser(null);
setResetPassword("");
}}
className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
</div>
<p className="text-xs text-muted-foreground">
This will invalidate all sessions and API keys for this user.
</p>
</form>
)}
{/* Users table */}
<div className="border border-border rounded-lg overflow-hidden">
{/* Table header */}
<div className="grid grid-cols-[1fr_100px_120px_60px] gap-2 px-4 py-2.5 bg-muted/40 border-b border-border text-xs font-medium text-muted-foreground uppercase tracking-wide">
<span>User</span>
<span>Role</span>
<span>Team</span>
<span />
</div>
{/* Table rows */}
{filteredUsers.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{search ? "No members match your search." : "No users found."}
</div>
) : (
users.map((u) => (
filteredUsers.map((u) => (
<div
key={u.id}
className="flex items-center justify-between p-3 rounded-lg border border-border bg-muted/20"
className="grid grid-cols-[1fr_100px_120px_60px] gap-2 items-center px-4 py-3 border-b border-border last:border-0 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-semibold text-sm">
{/* User cell */}
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-semibold text-sm shrink-0">
{u.username.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-foreground">{u.username}</p>
<p className="text-xs text-muted-foreground capitalize">{u.role}</p>
</div>
<span className="text-sm font-medium text-foreground truncate">{u.username}</span>
</div>
{/* Role badge */}
<div>
<span
className={cn(
"inline-block px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide",
u.role === "admin"
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{u.role}
</span>
</div>
{/* Team */}
<span className="text-sm text-foreground truncate">{u.team}</span>
{/* Actions */}
<div className="flex items-center gap-1 justify-end relative">
<button
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === u.id ? null : u.id);
}}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Actions"
>
<MoreVertical className="h-4 w-4" />
</button>
{/* Dropdown menu */}
{openMenuId === u.id && (
<div
className="absolute right-0 top-8 z-50 w-44 rounded-lg border border-border bg-background shadow-lg py-1"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
setEditingUser(u);
setEditRole(u.role);
setEditTeam(u.team);
setOpenMenuId(null);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
Edit Role / Team
</button>
<button
onClick={() => {
setResetPasswordUser(u);
setResetPassword("");
setOpenMenuId(null);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
Reset Password
</button>
<div className="border-t border-border my-1" />
<button
onClick={() => handleDeleteUser(u.id, u.username)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete User
</button>
</div>
)}
</div>
<button
onClick={() => handleDeleteUser(u.id, u.username)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={`Delete ${u.username}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))
)}
@ -691,7 +1008,7 @@ function ApiKeysSection() {
// Silently fail
}
},
[loadKeys]
[loadKeys],
);
if (loading) {
@ -779,7 +1096,9 @@ function ApiKeysSection() {
)}
{keys.length === 0 && !newKey && (
<p className="text-sm text-muted-foreground">No API keys yet. Generate one to get started.</p>
<p className="text-sm text-muted-foreground">
No API keys yet. Generate one to get started.
</p>
)}
</div>
);
@ -801,9 +1120,8 @@ function AboutSection() {
</div>
</div>
<p className="text-sm text-muted-foreground">
A self-hosted, privacy-first image processing suite with 37+ tools.
Resize, compress, convert, watermark, and automate your image workflows
without sending data to the cloud.
A self-hosted, privacy-first image processing suite with 37+ tools. Resize, compress,
convert, watermark, and automate your image workflows without sending data to the cloud.
</p>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">Version:</span>

View file

@ -1,6 +1,6 @@
import { Check, Copy, Loader2 } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Loader2, Copy, Check } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -83,9 +83,13 @@ export function BarcodeReadSettings() {
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80"
>
{copied ? (
<><Check className="h-3 w-3" /> Copied</>
<>
<Check className="h-3 w-3" /> Copied
</>
) : (
<><Copy className="h-3 w-3" /> Copy to clipboard</>
<>
<Copy className="h-3 w-3" /> Copy to clipboard
</>
)}
</button>
</>

View file

@ -1,8 +1,8 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { ProgressCard } from "@/components/common/progress-card";
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function BlurFacesSettings() {
const { files } = useFileStore();

View file

@ -1,13 +1,21 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function BorderSettings() {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("border");
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("border");
const [borderWidth, setBorderWidth] = useState(10);
const [borderColor, setBorderColor] = useState("#000000");
@ -33,12 +41,24 @@ export function BorderSettings() {
<label className="text-xs text-muted-foreground">Border Width</label>
<span className="text-xs font-mono text-foreground">{borderWidth}px</span>
</div>
<input type="range" min={0} max={100} value={borderWidth} onChange={(e) => setBorderWidth(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={0}
max={100}
value={borderWidth}
onChange={(e) => setBorderWidth(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Border Color</label>
<input type="color" value={borderColor} onChange={(e) => setBorderColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
<input
type="color"
value={borderColor}
onChange={(e) => setBorderColor(e.target.value)}
className="w-full mt-0.5 h-8 rounded border border-border"
/>
</div>
<div>
@ -46,7 +66,14 @@ export function BorderSettings() {
<label className="text-xs text-muted-foreground">Corner Radius</label>
<span className="text-xs font-mono text-foreground">{cornerRadius}px</span>
</div>
<input type="range" min={0} max={200} value={cornerRadius} onChange={(e) => setCornerRadius(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={0}
max={200}
value={cornerRadius}
onChange={(e) => setCornerRadius(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
<div>
@ -54,7 +81,14 @@ export function BorderSettings() {
<label className="text-xs text-muted-foreground">Padding</label>
<span className="text-xs font-mono text-foreground">{padding}px</span>
</div>
<input type="range" min={0} max={100} value={padding} onChange={(e) => setPadding(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={0}
max={100}
value={padding}
onChange={(e) => setPadding(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
<div>
@ -62,7 +96,14 @@ export function BorderSettings() {
<label className="text-xs text-muted-foreground">Shadow</label>
<span className="text-xs font-mono text-foreground">{shadowBlur}px</span>
</div>
<input type="range" min={0} max={50} value={shadowBlur} onChange={(e) => setShadowBlur(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={0}
max={50}
value={shadowBlur}
onChange={(e) => setShadowBlur(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
@ -94,7 +135,11 @@ export function BorderSettings() {
)}
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>

View file

@ -1,6 +1,6 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -60,10 +60,12 @@ export function BulkRenameSettings() {
const ext = f.name.includes(".") ? f.name.slice(f.name.lastIndexOf(".")) : "";
const idx = startIndex + i;
const padded = String(idx).padStart(String(files.length + startIndex).length, "0");
return pattern
.replace(/\{\{index\}\}/g, String(idx))
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, f.name.replace(ext, "")) + ext;
return (
pattern
.replace(/\{\{index\}\}/g, String(idx))
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, f.name.replace(ext, "")) + ext
);
})
: [];
@ -84,8 +86,13 @@ export function BulkRenameSettings() {
<div>
<label className="text-xs text-muted-foreground">Start Index</label>
<input type="number" value={startIndex} onChange={(e) => setStartIndex(Number(e.target.value))} min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
<input
type="number"
value={startIndex}
onChange={(e) => setStartIndex(Number(e.target.value))}
min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
{previewNames.length > 0 && (
@ -93,7 +100,10 @@ export function BulkRenameSettings() {
<label className="text-xs text-muted-foreground">Preview</label>
<div className="mt-1 space-y-0.5">
{previewNames.map((name, i) => (
<div key={i} className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate">
<div
key={i}
className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate"
>
{name}
</div>
))}

View file

@ -1,6 +1,6 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -18,7 +18,8 @@ const LAYOUTS: { value: Layout; label: string }[] = [
];
export function CollageSettings() {
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
const [layout, setLayout] = useState<Layout>("2x2");
const [gap, setGap] = useState(4);
const [backgroundColor, setBackgroundColor] = useState("#FFFFFF");
@ -89,12 +90,24 @@ export function CollageSettings() {
<label className="text-xs text-muted-foreground">Gap</label>
<span className="text-xs font-mono text-foreground">{gap}px</span>
</div>
<input type="range" min={0} max={50} value={gap} onChange={(e) => setGap(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={0}
max={50}
value={gap}
onChange={(e) => setGap(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Background Color</label>
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-full mt-0.5 h-8 rounded border border-border"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
@ -116,7 +129,11 @@ export function CollageSettings() {
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download Collage
</a>

View file

@ -1,6 +1,6 @@
import { Check, Copy, Loader2 } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Loader2, Copy, Check } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -83,9 +83,7 @@ export function ColorPaletteSettings() {
className="w-6 h-6 rounded border border-border shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-xs font-mono text-foreground flex-1 text-left">
{color}
</span>
<span className="text-xs font-mono text-foreground flex-1 text-left">{color}</span>
{copiedIdx === i ? (
<Check className="h-3 w-3 text-green-500 shrink-0" />
) : (

View file

@ -1,8 +1,8 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type Tab = "basic" | "channels" | "effects";
type Effect = "none" | "grayscale" | "sepia" | "invert";
@ -14,8 +14,16 @@ interface ColorSettingsProps {
export function ColorSettings({ toolId }: ColorSettingsProps) {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor(toolId);
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor(toolId);
const [tab, setTab] = useState<Tab>(() => {
if (toolId === "color-channels") return "channels";
@ -84,9 +92,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
key={t.id}
onClick={() => setTab(t.id)}
className={`flex-1 text-xs py-1.5 rounded ${
tab === t.id
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
tab === t.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
}`}
>
{t.label}

View file

@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { useFileStore } from "@/stores/file-store";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -78,10 +78,7 @@ export function CompareSettings() {
Similarity: {similarity.toFixed(1)}%
</p>
<div className="mt-1 h-2 bg-background rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${similarity}%` }}
/>
<div className="h-full rounded-full bg-primary" style={{ width: `${similarity}%` }} />
</div>
</div>
)}
@ -96,7 +93,11 @@ export function CompareSettings() {
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download Diff Image
</a>

View file

@ -1,13 +1,14 @@
import { useState, useRef } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { useFileStore } from "@/stores/file-store";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function ComposeSettings() {
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
const [overlayFile, setOverlayFile] = useState<File | null>(null);
const [x, setX] = useState(0);
const [y, setY] = useState(0);
@ -81,13 +82,23 @@ export function ComposeSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">X Position</label>
<input type="number" value={x} onChange={(e) => setX(Number(e.target.value))} min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
<input
type="number"
value={x}
onChange={(e) => setX(Number(e.target.value))}
min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Y Position</label>
<input type="number" value={y} onChange={(e) => setY(Number(e.target.value))} min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
<input
type="number"
value={y}
onChange={(e) => setY(Number(e.target.value))}
min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
@ -96,7 +107,14 @@ export function ComposeSettings() {
<label className="text-xs text-muted-foreground">Opacity</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input type="range" min={0} max={100} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={0}
max={100}
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
<div>
@ -138,7 +156,11 @@ export function ComposeSettings() {
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>

View file

@ -1,15 +1,23 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type CompressMode = "quality" | "targetSize";
export function CompressSettings() {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("compress");
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("compress");
const [mode, setMode] = useState<CompressMode>("quality");
const [quality, setQuality] = useState(75);
@ -30,8 +38,7 @@ export function CompressSettings() {
};
const hasFile = files.length > 0;
const canProcess =
mode === "quality" || (mode === "targetSize" && Number(targetSizeKb) > 0);
const canProcess = mode === "quality" || (mode === "targetSize" && Number(targetSizeKb) > 0);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@ -103,11 +110,7 @@ export function CompressSettings() {
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
<p className="font-medium text-foreground">
Saved:{" "}
{originalSize > 0
? ((1 - processedSize / originalSize) * 100).toFixed(1)
: "0"}
%
Saved: {originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}%
</p>
</div>
)}

View file

@ -1,16 +1,24 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
export function ConvertSettings() {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("convert");
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("convert");
const [format, setFormat] = useState<string>("png");
const [quality, setQuality] = useState(85);
@ -98,10 +106,7 @@ export function ConvertSettings() {
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
<p>
Savings:{" "}
{originalSize > 0
? ((1 - processedSize / originalSize) * 100).toFixed(1)
: "0"}
%
{originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}%
</p>
</div>
)}

View file

@ -1,4 +1,4 @@
import { useRef, useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import ReactCrop, { type Crop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
@ -68,9 +68,7 @@ export function CropCanvas({
return;
} else if (e.key === "Enter") {
// Submit the crop form (find and submit closest form)
const form = document.querySelector<HTMLFormElement>(
'form[data-crop-form]',
);
const form = document.querySelector<HTMLFormElement>("form[data-crop-form]");
if (form) form.requestSubmit();
e.preventDefault();
return;
@ -94,17 +92,11 @@ export function CropCanvas({
}, []);
// Calculate pixel dimensions for the badge
const pixelWidth =
imgDimensions ? Math.round((crop.width / 100) * imgDimensions.width) : 0;
const pixelHeight =
imgDimensions ? Math.round((crop.height / 100) * imgDimensions.height) : 0;
const pixelWidth = imgDimensions ? Math.round((crop.width / 100) * imgDimensions.width) : 0;
const pixelHeight = imgDimensions ? Math.round((crop.height / 100) * imgDimensions.height) : 0;
return (
<div
ref={containerRef}
className="flex flex-col w-full h-full max-w-4xl mx-auto outline-none"
tabIndex={0}
>
<div ref={containerRef} className="flex flex-col w-full h-full max-w-4xl mx-auto outline-none">
{/* Crop area */}
<div className="flex-1 flex items-center justify-center overflow-hidden bg-muted/20 p-4">
<ReactCrop

View file

@ -1,9 +1,9 @@
import { ArrowLeftRight, Download, Grid3x3 } from "lucide-react";
import { useCallback } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, ArrowLeftRight, Grid3x3 } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
import type { Crop } from "react-image-crop";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
const ASPECT_PRESETS = [
{ label: "Free", value: undefined as number | undefined },
@ -35,14 +35,8 @@ export function CropSettings({
onGridToggle,
}: CropSettingsProps) {
const { files } = useFileStore();
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
progress,
} = useToolProcessor("crop");
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("crop");
const { crop, aspect, showGrid, imgDimensions } = cropState;
@ -66,10 +60,7 @@ export function CropSettings({
if (!imgDimensions) return;
const newCrop = { ...crop };
if (field === "left") {
newCrop.x = Math.max(
0,
Math.min((value / imgDimensions.width) * 100, 100 - newCrop.width),
);
newCrop.x = Math.max(0, Math.min((value / imgDimensions.width) * 100, 100 - newCrop.width));
} else if (field === "top") {
newCrop.y = Math.max(
0,
@ -116,7 +107,7 @@ export function CropSettings({
} else {
// Desired ratio is taller than image — use full height, shrink width
newHeight = 100;
newWidth = (imgDimensions.height * value / imgDimensions.width) * 100;
newWidth = ((imgDimensions.height * value) / imgDimensions.width) * 100;
}
onCropChange({
unit: "%",
@ -215,9 +206,7 @@ export function CropSettings({
<input
type="number"
value={pixels.left}
onChange={(e) =>
handlePixelChange("left", Number(e.target.value))
}
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
min={0}
max={imgDimensions ? imgDimensions.width - 1 : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
@ -230,9 +219,7 @@ export function CropSettings({
<input
type="number"
value={pixels.top}
onChange={(e) =>
handlePixelChange("top", Number(e.target.value))
}
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
min={0}
max={imgDimensions ? imgDimensions.height - 1 : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
@ -245,9 +232,7 @@ export function CropSettings({
<input
type="number"
value={pixels.width}
onChange={(e) =>
handlePixelChange("width", Number(e.target.value))
}
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
min={1}
max={imgDimensions ? imgDimensions.width : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
@ -260,9 +245,7 @@ export function CropSettings({
<input
type="number"
value={pixels.height}
onChange={(e) =>
handlePixelChange("height", Number(e.target.value))
}
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
min={1}
max={imgDimensions ? imgDimensions.height : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"

View file

@ -1,7 +1,7 @@
import { useState, useRef } from "react";
import { useFileStore } from "@/stores/file-store";
import { ProgressCard } from "@/components/common/progress-card";
import { Download, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useFileStore } from "@/stores/file-store";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -116,19 +116,15 @@ export function EraseObjectSettings() {
<div>
<label className="text-sm font-medium text-muted-foreground">Mask Image</label>
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5">
Upload a black &amp; white mask where white areas will be erased. Create the mask in any image editor.
Upload a black &amp; white mask where white areas will be erased. Create the mask in any
image editor.
</p>
<label className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary">
<Upload className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{maskFile ? maskFile.name : "Select mask image..."}
</span>
<input
type="file"
accept="image/*"
onChange={handleMaskSelect}
className="hidden"
/>
<input type="file" accept="image/*" onChange={handleMaskSelect} className="hidden" />
</label>
</div>

View file

@ -1,6 +1,6 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -62,8 +62,8 @@ export function FaviconSettings() {
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
Upload a square image (recommended 512x512 or larger) to generate all
favicon and app icon sizes.
Upload a square image (recommended 512x512 or larger) to generate all favicon and app icon
sizes.
</p>
<div>
@ -76,9 +76,7 @@ export function FaviconSettings() {
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1">
+ manifest.json + HTML snippet
</p>
<p className="text-[10px] text-muted-foreground mt-1">+ manifest.json + HTML snippet</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}

View file

@ -1,6 +1,6 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";

View file

@ -1,8 +1,8 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function GifToolsSettings() {
const { files } = useFileStore();
@ -54,26 +54,46 @@ export function GifToolsSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<input type="number" value={width} onChange={(e) => setWidth(e.target.value)} placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
<input
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
<input
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" checked={optimize} onChange={(e) => setOptimize(e.target.checked)} className="rounded" />
<input
type="checkbox"
checked={optimize}
onChange={(e) => setOptimize(e.target.checked)}
className="rounded"
/>
Optimize file size
</label>
</>
) : (
<div>
<label className="text-xs text-muted-foreground">Frame Number</label>
<input type="number" value={extractFrame} onChange={(e) => setExtractFrame(e.target.value)} min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
<input
type="number"
value={extractFrame}
onChange={(e) => setExtractFrame(e.target.value)}
min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">Frame 0 is the first frame</p>
</div>
)}
@ -107,7 +127,11 @@ export function GifToolsSettings() {
)}
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>

View file

@ -1,6 +1,6 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -52,8 +52,8 @@ export function ImageToPdfSettings() {
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
{files.length} image{files.length !== 1 ? "s" : ""} will be combined
into a PDF, one image per page.
{files.length} image{files.length !== 1 ? "s" : ""} will be combined into a PDF, one image
per page.
</p>
<div>
@ -93,7 +93,14 @@ export function ImageToPdfSettings() {
<label className="text-xs text-muted-foreground">Margin</label>
<span className="text-xs font-mono text-foreground">{margin}pt</span>
</div>
<input type="range" min={0} max={100} value={margin} onChange={(e) => setMargin(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={0}
max={100}
value={margin}
onChange={(e) => setMargin(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
@ -108,7 +115,11 @@ export function ImageToPdfSettings() {
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download PDF
</a>

View file

@ -1,6 +1,6 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -93,7 +93,9 @@ export function InfoSettings() {
<div className="space-y-3">
<div className="grid grid-cols-2 gap-1 text-xs">
<div className="text-muted-foreground">Dimensions</div>
<div className="text-foreground font-mono">{info.width} x {info.height}</div>
<div className="text-foreground font-mono">
{info.width} x {info.height}
</div>
<div className="text-muted-foreground">Format</div>
<div className="text-foreground font-mono">{info.format}</div>
<div className="text-muted-foreground">File Size</div>
@ -125,7 +127,9 @@ export function InfoSettings() {
{info.histogram.map((ch) => (
<div key={ch.channel} className="space-y-0.5">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${channelColors[ch.channel] ?? "bg-gray-400"}`} />
<div
className={`w-2 h-2 rounded-full ${channelColors[ch.channel] ?? "bg-gray-400"}`}
/>
<span className="text-xs text-foreground capitalize">{ch.channel}</span>
</div>
<div className="flex gap-2 text-[10px] text-muted-foreground font-mono">

View file

@ -1,7 +1,7 @@
import { useState, useRef } from "react";
import { useFileStore } from "@/stores/file-store";
import { Check, Copy } from "lucide-react";
import { useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { Copy, Check } from "lucide-react";
import { useFileStore } from "@/stores/file-store";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -204,11 +204,7 @@ export function OcrSettings() {
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "Copied" : "Copy"}
</button>
</div>
@ -219,9 +215,7 @@ export function OcrSettings() {
className="w-full px-2 py-1.5 rounded border border-border bg-muted text-xs text-foreground font-mono resize-y"
/>
{text.length > 0 && (
<p className="text-[10px] text-muted-foreground">
{text.length} characters extracted
</p>
<p className="text-[10px] text-muted-foreground">{text.length} characters extracted</p>
)}
</div>
)}

View file

@ -1,26 +1,25 @@
import { useState, useCallback } from "react";
import { TOOLS } from "@stirling-image/shared";
import * as icons from "lucide-react";
import {
Plus,
X,
ChevronUp,
ChevronDown,
ChevronRight,
ChevronUp,
Download,
FileImage,
Loader2,
Play,
Plus,
Save,
Upload,
Loader2,
FileImage,
Download,
X,
} from "lucide-react";
import * as icons from "lucide-react";
import { TOOLS } from "@stirling-image/shared";
import { useCallback, useState } from "react";
import { cn } from "@/lib/utils";
import { PipelineStepSettings } from "./pipeline-step-settings";
/** Tools that can be used as pipeline steps (excludes pipeline/batch/multi-file tools). */
const PIPELINE_TOOLS = TOOLS.filter(
(t) =>
!["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id)
(t) => !["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id),
);
export interface PipelineStep {
@ -71,7 +70,7 @@ export function PipelineBuilder({
setShowToolPicker(false);
setExpandedStep(step.id);
},
[steps, onStepsChange]
[steps, onStepsChange],
);
const removeStep = useCallback(
@ -79,7 +78,7 @@ export function PipelineBuilder({
onStepsChange(steps.filter((s) => s.id !== id));
if (expandedStep === id) setExpandedStep(null);
},
[steps, onStepsChange, expandedStep]
[steps, onStepsChange, expandedStep],
);
const moveStep = useCallback(
@ -92,14 +91,14 @@ export function PipelineBuilder({
[newSteps[idx], newSteps[newIdx]] = [newSteps[newIdx], newSteps[idx]];
onStepsChange(newSteps);
},
[steps, onStepsChange]
[steps, onStepsChange],
);
const updateStepSettings = useCallback(
(id: string, newSettings: Record<string, unknown>) => {
onStepsChange(steps.map((s) => (s.id === id ? { ...s, settings: newSettings } : s)));
},
[steps, onStepsChange]
[steps, onStepsChange],
);
const handleFileSelect = useCallback(() => {
@ -132,10 +131,7 @@ export function PipelineBuilder({
onExecute(file);
}, [file, onExecute]);
const iconsMap = icons as unknown as Record<
string,
React.ComponentType<{ className?: string }>
>;
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
return (
<div className="space-y-6">
@ -147,7 +143,7 @@ export function PipelineBuilder({
"rounded-xl border-2 border-dashed p-6 text-center transition-colors",
file
? "border-primary/30 bg-primary/5"
: "border-border bg-muted/20 hover:border-primary/30"
: "border-border bg-muted/20 hover:border-primary/30",
)}
>
{file ? (
@ -203,9 +199,7 @@ export function PipelineBuilder({
{/* Tool icon + name */}
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium text-foreground flex-1">
{tool.name}
</span>
<span className="text-sm font-medium text-foreground flex-1">{tool.name}</span>
{/* Controls */}
<div className="flex items-center gap-0.5 shrink-0">
@ -247,9 +241,7 @@ export function PipelineBuilder({
{/* Expanded settings */}
{isExpanded && (
<div className="border-t border-border p-3 bg-muted/10 space-y-3">
<p className="text-xs text-muted-foreground">
{tool.description}
</p>
<p className="text-xs text-muted-foreground">{tool.description}</p>
<PipelineStepSettings
toolId={step.toolId}
settings={step.settings}
@ -286,9 +278,7 @@ export function PipelineBuilder({
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground">{tool.name}</div>
<div className="text-xs text-muted-foreground truncate">
{tool.description}
</div>
<div className="text-xs text-muted-foreground truncate">{tool.description}</div>
</div>
</button>
);
@ -365,7 +355,6 @@ export function PipelineBuilder({
onChange={(e) => setSaveName(e.target.value)}
placeholder="Pipeline name"
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1"
autoFocus
/>
<input
type="text"

View file

@ -17,7 +17,13 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
resize: [
{ key: "width", label: "Width (px)", type: "number", min: 1, placeholder: "Auto" },
{ key: "height", label: "Height (px)", type: "number", min: 1, placeholder: "Auto" },
{ key: "percentage", label: "Scale (%)", type: "number", min: 1, placeholder: "Use instead of width/height" },
{
key: "percentage",
label: "Scale (%)",
type: "number",
min: 1,
placeholder: "Use instead of width/height",
},
{
key: "fit",
label: "Fit Mode",
@ -40,7 +46,15 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
],
rotate: [
{ key: "angle", label: "Angle (degrees)", type: "number", min: -360, max: 360, step: 90, defaultValue: 0 },
{
key: "angle",
label: "Angle (degrees)",
type: "number",
min: -360,
max: 360,
step: 90,
defaultValue: 0,
},
{ key: "horizontal", label: "Flip horizontal", type: "boolean", defaultValue: false },
{ key: "vertical", label: "Flip vertical", type: "boolean", defaultValue: false },
],
@ -59,7 +73,14 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
{ value: "gif", label: "GIF" },
],
},
{ key: "quality", label: "Quality (1-100)", type: "number", min: 1, max: 100, placeholder: "Auto" },
{
key: "quality",
label: "Quality (1-100)",
type: "number",
min: 1,
max: 100,
placeholder: "Auto",
},
],
compress: [
@ -125,12 +146,26 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
],
"brightness-contrast": [
{ key: "brightness", label: "Brightness", type: "number", min: -100, max: 100, defaultValue: 0 },
{
key: "brightness",
label: "Brightness",
type: "number",
min: -100,
max: 100,
defaultValue: 0,
},
{ key: "contrast", label: "Contrast", type: "number", min: -100, max: 100, defaultValue: 0 },
],
saturation: [
{ key: "saturation", label: "Saturation", type: "number", min: -100, max: 100, defaultValue: 0 },
{
key: "saturation",
label: "Saturation",
type: "number",
min: -100,
max: 100,
defaultValue: 0,
},
],
"color-channels": [
@ -157,8 +192,20 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
"replace-color": [
{ key: "sourceColor", label: "Source color", type: "color", defaultValue: "#FF0000" },
{ key: "targetColor", label: "Target color", type: "color", defaultValue: "#00FF00" },
{ key: "makeTransparent", label: "Make transparent instead", type: "boolean", defaultValue: false },
{ key: "tolerance", label: "Tolerance (0-255)", type: "number", min: 0, max: 255, defaultValue: 30 },
{
key: "makeTransparent",
label: "Make transparent instead",
type: "boolean",
defaultValue: false,
},
{
key: "tolerance",
label: "Tolerance (0-255)",
type: "number",
min: 0,
max: 255,
defaultValue: 30,
},
],
"watermark-text": [
@ -180,7 +227,14 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
{ value: "tiled", label: "Tiled" },
],
},
{ key: "rotation", label: "Rotation (degrees)", type: "number", min: -360, max: 360, defaultValue: 0 },
{
key: "rotation",
label: "Rotation (degrees)",
type: "number",
min: -360,
max: 360,
defaultValue: 0,
},
],
"watermark-image": [
@ -230,7 +284,14 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
border: [
{ key: "borderWidth", label: "Width (px)", type: "number", min: 0, max: 200, defaultValue: 10 },
{ key: "borderColor", label: "Color", type: "color", defaultValue: "#000000" },
{ key: "cornerRadius", label: "Corner radius", type: "number", min: 0, max: 500, defaultValue: 0 },
{
key: "cornerRadius",
label: "Corner radius",
type: "number",
min: 0,
max: 500,
defaultValue: 0,
},
{ key: "padding", label: "Padding (px)", type: "number", min: 0, max: 200, defaultValue: 0 },
{ key: "shadowBlur", label: "Shadow blur", type: "number", min: 0, max: 50, defaultValue: 0 },
{ key: "shadowColor", label: "Shadow color", type: "color", defaultValue: "#00000080" },
@ -243,7 +304,15 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
"blur-faces": [
{ key: "blurRadius", label: "Blur radius", type: "number", min: 1, max: 100, defaultValue: 30 },
{ key: "sensitivity", label: "Sensitivity (0-1)", type: "number", min: 0, max: 1, step: 0.1, defaultValue: 0.5 },
{
key: "sensitivity",
label: "Sensitivity (0-1)",
type: "number",
min: 0,
max: 1,
step: 0.1,
defaultValue: 0.5,
},
],
upscale: [
@ -276,7 +345,14 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
{ value: "color", label: "Color" },
],
},
{ key: "threshold", label: "Threshold (0-255)", type: "number", min: 0, max: 255, defaultValue: 128 },
{
key: "threshold",
label: "Threshold (0-255)",
type: "number",
min: 0,
max: 255,
defaultValue: 128,
},
{
key: "detail",
label: "Detail",
@ -309,7 +385,13 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
"gif-tools": [
{ key: "width", label: "Width (px)", type: "number", min: 1, max: 4096, placeholder: "Auto" },
{ key: "height", label: "Height (px)", type: "number", min: 1, max: 4096, placeholder: "Auto" },
{ key: "extractFrame", label: "Extract frame #", type: "number", min: 0, placeholder: "All frames" },
{
key: "extractFrame",
label: "Extract frame #",
type: "number",
min: 0,
placeholder: "All frames",
},
{ key: "optimize", label: "Optimize", type: "boolean", defaultValue: false },
],
@ -340,7 +422,13 @@ const TOOL_FIELDS: Record<string, FieldDef[]> = {
],
"bulk-rename": [
{ key: "pattern", label: "Pattern", type: "text", placeholder: "image-{{index}}", defaultValue: "image-{{index}}" },
{
key: "pattern",
label: "Pattern",
type: "text",
placeholder: "image-{{index}}",
defaultValue: "image-{{index}}",
},
{ key: "startIndex", label: "Start index", type: "number", min: 0, defaultValue: 1 },
],
@ -443,7 +531,10 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
type="number"
value={value != null && value !== "" ? Number(value) : ""}
onChange={(e) =>
updateField(field.key, e.target.value === "" ? undefined : Number(e.target.value))
updateField(
field.key,
e.target.value === "" ? undefined : Number(e.target.value),
)
}
min={field.min}
max={field.max}
@ -515,7 +606,10 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "boolean":
return (
<label key={field.key} className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<label
key={field.key}
className="flex items-center gap-2 text-sm text-foreground cursor-pointer"
>
<input
type="checkbox"
checked={Boolean(value)}

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
@ -67,7 +67,15 @@ export function QrGenerateSettings() {
<label className="text-xs text-muted-foreground">Size</label>
<span className="text-xs font-mono text-foreground">{size}px</span>
</div>
<input type="range" min={100} max={2000} step={50} value={size} onChange={(e) => setSize(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={100}
max={2000}
step={50}
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
<div>
@ -87,11 +95,21 @@ export function QrGenerateSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Foreground</label>
<input type="color" value={foreground} onChange={(e) => setForeground(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
<input
type="color"
value={foreground}
onChange={(e) => setForeground(e.target.value)}
className="w-full mt-0.5 h-8 rounded border border-border"
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Background</label>
<input type="color" value={background} onChange={(e) => setBackground(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
<input
type="color"
value={background}
onChange={(e) => setBackground(e.target.value)}
className="w-full mt-0.5 h-8 rounded border border-border"
/>
</div>
</div>
@ -108,8 +126,17 @@ export function QrGenerateSettings() {
{previewUrl && (
<div className="flex flex-col items-center gap-2">
<img src={previewUrl} alt="QR Code" className="max-w-full rounded border border-border" style={{ maxHeight: 200 }} />
<a href={downloadUrl!} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<img
src={previewUrl}
alt="QR Code"
className="max-w-full rounded border border-border"
style={{ maxHeight: 200 }}
/>
<a
href={downloadUrl!}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download QR Code
</a>

View file

@ -1,8 +1,8 @@
import { Download, ImageIcon, Package, User } from "lucide-react";
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { ProgressCard } from "@/components/common/progress-card";
import { Download, User, Package, ImageIcon } from "lucide-react";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type SubjectType = "people" | "products" | "general";
type Quality = "fast" | "balanced" | "best";
@ -15,9 +15,9 @@ type BgModel =
| "u2net";
const MODEL_MAP: Record<SubjectType, Record<Quality, BgModel>> = {
people: { fast: "u2net", balanced: "birefnet-portrait", best: "birefnet-portrait" },
products: { fast: "u2net", balanced: "bria-rmbg", best: "birefnet-general" },
general: { fast: "u2net", balanced: "birefnet-general-lite", best: "birefnet-general" },
people: { fast: "u2net", balanced: "birefnet-portrait", best: "birefnet-portrait" },
products: { fast: "u2net", balanced: "bria-rmbg", best: "birefnet-general" },
general: { fast: "u2net", balanced: "birefnet-general-lite", best: "birefnet-general" },
};
const SUBJECT_OPTIONS: { value: SubjectType; label: string; icon: typeof User }[] = [
@ -126,9 +126,7 @@ export function RemoveBgSettings() {
{/* Background color - intuitive preset buttons */}
<div>
<label className="text-sm font-medium text-muted-foreground">
Output Background
</label>
<label className="text-sm font-medium text-muted-foreground">Output Background</label>
<div className="flex gap-1.5 mt-1.5 flex-wrap">
{BG_PRESETS.map((preset) => (
<button

View file

@ -1,13 +1,21 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function ReplaceColorSettings() {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("replace-color");
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("replace-color");
const [sourceColor, setSourceColor] = useState("#FF0000");
const [targetColor, setTargetColor] = useState("#00FF00");
@ -30,13 +38,23 @@ export function ReplaceColorSettings() {
<div>
<label className="text-xs text-muted-foreground">Source Color (to replace)</label>
<div className="flex items-center gap-2 mt-0.5">
<input type="color" value={sourceColor} onChange={(e) => setSourceColor(e.target.value)} className="w-10 h-8 rounded border border-border" />
<input
type="color"
value={sourceColor}
onChange={(e) => setSourceColor(e.target.value)}
className="w-10 h-8 rounded border border-border"
/>
<span className="text-xs font-mono text-foreground">{sourceColor}</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" checked={makeTransparent} onChange={(e) => setMakeTransparent(e.target.checked)} className="rounded" />
<input
type="checkbox"
checked={makeTransparent}
onChange={(e) => setMakeTransparent(e.target.checked)}
className="rounded"
/>
Make transparent instead
</label>
@ -44,7 +62,12 @@ export function ReplaceColorSettings() {
<div>
<label className="text-xs text-muted-foreground">Target Color (replacement)</label>
<div className="flex items-center gap-2 mt-0.5">
<input type="color" value={targetColor} onChange={(e) => setTargetColor(e.target.value)} className="w-10 h-8 rounded border border-border" />
<input
type="color"
value={targetColor}
onChange={(e) => setTargetColor(e.target.value)}
className="w-10 h-8 rounded border border-border"
/>
<span className="text-xs font-mono text-foreground">{targetColor}</span>
</div>
</div>
@ -55,7 +78,14 @@ export function ReplaceColorSettings() {
<label className="text-xs text-muted-foreground">Tolerance</label>
<span className="text-xs font-mono text-foreground">{tolerance}</span>
</div>
<input type="range" min={0} max={255} value={tolerance} onChange={(e) => setTolerance(Number(e.target.value))} className="w-full mt-1" />
<input
type="range"
min={0}
max={255}
value={tolerance}
onChange={(e) => setTolerance(Number(e.target.value))}
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Exact match</span>
<span>Wide range</span>
@ -91,7 +121,11 @@ export function ReplaceColorSettings() {
)}
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>

View file

@ -1,9 +1,9 @@
import { useState } from "react";
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Link, Unlink } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type ResizeTab = "presets" | "custom" | "scale";
type FitMode = "cover" | "contain" | "fill";
@ -67,9 +67,7 @@ export function ResizeSettings() {
const canProcess =
hasFile &&
!processing &&
(tab === "scale"
? Number(percentage) > 0
: Boolean(width) || Boolean(height));
(tab === "scale" ? Number(percentage) > 0 : Boolean(width) || Boolean(height));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();

Some files were not shown because too many files have changed in this diff Show more