forked from syntaxbullet/aurorabot
feat: add item creation tools
This commit is contained in:
187
CLAUDE.md
Normal file
187
CLAUDE.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
bun --watch bot/index.ts # Run bot + API with hot reload
|
||||||
|
docker compose up # Start all services (bot, API, database)
|
||||||
|
docker compose up app # Start just the app (bot + API)
|
||||||
|
docker compose up db # Start just the database
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
bun test # Run all tests
|
||||||
|
bun test path/to/file.test.ts # Run a single test file
|
||||||
|
bun test shared/modules/economy # Run tests in a directory
|
||||||
|
bun test --watch # Watch mode
|
||||||
|
|
||||||
|
# Database
|
||||||
|
bun run db:push:local # Push schema changes (local)
|
||||||
|
bun run db:studio # Open Drizzle Studio (localhost:4983)
|
||||||
|
bun run generate # Generate Drizzle migrations (Docker)
|
||||||
|
bun run migrate # Apply migrations (Docker)
|
||||||
|
|
||||||
|
# Admin Panel
|
||||||
|
bun run panel:dev # Start Vite dev server for dashboard
|
||||||
|
bun run panel:build # Build React dashboard for production
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Aurora is a Discord RPG bot + REST API running as a **single Bun process**. The bot and API share the same database client and services.
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/ # Discord bot
|
||||||
|
├── commands/ # Slash commands by category (admin, economy, inventory, etc.)
|
||||||
|
├── events/ # Discord event handlers
|
||||||
|
├── lib/ # BotClient, handlers, loaders, embed helpers, commandUtils
|
||||||
|
├── modules/ # Feature modules (views, interactions per domain)
|
||||||
|
└── graphics/ # Canvas-based image generation (@napi-rs/canvas)
|
||||||
|
|
||||||
|
shared/ # Shared between bot and API
|
||||||
|
├── db/ # Drizzle ORM client + schema (users, economy, inventory, quests, etc.)
|
||||||
|
├── lib/ # env, config, errors, logger, types, utils
|
||||||
|
└── modules/ # Domain services (economy, user, inventory, quest, moderation, etc.)
|
||||||
|
|
||||||
|
api/ # REST API (Bun HTTP server)
|
||||||
|
└── src/routes/ # Route handlers for each domain
|
||||||
|
|
||||||
|
panel/ # React admin dashboard (Vite + Tailwind + Radix UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key architectural details:**
|
||||||
|
- Bot and API both import from `shared/` — do not duplicate logic.
|
||||||
|
- Services in `shared/modules/` are singleton objects, not classes.
|
||||||
|
- The database uses PostgreSQL 16+ via Drizzle ORM with `bigint` mode for Discord IDs and currency.
|
||||||
|
- Feature modules follow a strict file suffix convention (see below).
|
||||||
|
|
||||||
|
## Import Conventions
|
||||||
|
|
||||||
|
Use path aliases (defined in `tsconfig.json`). Order: external packages → aliases → relative.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SlashCommandBuilder } from "discord.js"; // external
|
||||||
|
import { economyService } from "@shared/modules/economy/economy.service"; // alias
|
||||||
|
import { users } from "@db/schema"; // alias
|
||||||
|
import { createErrorEmbed } from "@lib/embeds"; // alias
|
||||||
|
import { localHelper } from "./helper"; // relative
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aliases:**
|
||||||
|
- `@/*` → `bot/`
|
||||||
|
- `@shared/*` → `shared/`
|
||||||
|
- `@db/*` → `shared/db/`
|
||||||
|
- `@lib/*` → `bot/lib/`
|
||||||
|
- `@modules/*` → `bot/modules/`
|
||||||
|
- `@commands/*` → `bot/commands/`
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Module File Suffixes
|
||||||
|
|
||||||
|
- `*.view.ts` — Creates Discord embeds/components
|
||||||
|
- `*.interaction.ts` — Handles button/select/modal interactions
|
||||||
|
- `*.service.ts` — Business logic (lives in `shared/modules/`)
|
||||||
|
- `*.types.ts` — Module-specific TypeScript types
|
||||||
|
- `*.test.ts` — Tests (co-located with source)
|
||||||
|
|
||||||
|
### Command Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const commandName = createCommand({
|
||||||
|
data: new SlashCommandBuilder().setName("name").setDescription("desc"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await withCommandErrorHandling(interaction, async () => {
|
||||||
|
const result = await service.method();
|
||||||
|
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
|
}, { ephemeral: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`withCommandErrorHandling` (from `@lib/commandUtils`) handles `deferReply`, `UserError` display, and unexpected error logging automatically.
|
||||||
|
|
||||||
|
### Service Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const serviceName = {
|
||||||
|
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
// database operations
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UserError, SystemError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
throw new UserError("You don't have enough coins!"); // shown to user
|
||||||
|
throw new SystemError("DB connection failed"); // logged, generic message shown
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Transactions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withTransaction } from "@/lib/db";
|
||||||
|
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
const user = await tx.query.users.findFirst({ where: eq(users.id, id) });
|
||||||
|
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, id));
|
||||||
|
return user;
|
||||||
|
}, existingTx); // pass existing tx for nested transactions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Mock modules **before** imports. Use `bun:test`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: { query: mockQuery },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("serviceName", () => {
|
||||||
|
beforeEach(() => mockFn.mockClear());
|
||||||
|
it("should handle expected case", async () => {
|
||||||
|
mockFn.mockResolvedValue(testData);
|
||||||
|
const result = await service.method(input);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
| Element | Convention | Example |
|
||||||
|
| ---------------- | ---------------------- | -------------------------------- |
|
||||||
|
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||||
|
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||||
|
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||||
|
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||||
|
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||||
|
| Services | camelCase singleton | `economyService`, `userService` |
|
||||||
|
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||||
|
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||||
|
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||||
|
| API routes | kebab-case | `/api/guild-settings` |
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| Purpose | File |
|
||||||
|
| ----------------- | -------------------------- |
|
||||||
|
| Bot entry point | `bot/index.ts` |
|
||||||
|
| Discord client | `bot/lib/BotClient.ts` |
|
||||||
|
| DB schema index | `shared/db/schema.ts` |
|
||||||
|
| Error classes | `shared/lib/errors.ts` |
|
||||||
|
| Environment vars | `shared/lib/env.ts` |
|
||||||
|
| Config loader | `shared/lib/config.ts` |
|
||||||
|
| Embed helpers | `bot/lib/embeds.ts` |
|
||||||
|
| Command utils | `bot/lib/commandUtils.ts` |
|
||||||
|
| API server | `api/src/server.ts` |
|
||||||
47
bun.lock
47
bun.lock
@@ -24,6 +24,7 @@
|
|||||||
"name": "panel",
|
"name": "panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@imgly/background-removal": "^1.7.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
@@ -154,6 +155,8 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@imgly/background-removal": ["@imgly/background-removal@1.7.0", "", { "dependencies": { "lodash-es": "^4.17.21", "ndarray": "~1.0.0", "zod": "^3.23.8" }, "peerDependencies": { "onnxruntime-web": "1.21.0" } }, "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -188,6 +191,26 @@
|
|||||||
|
|
||||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||||
|
|
||||||
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||||
|
|
||||||
|
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||||
|
|
||||||
|
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||||
|
|
||||||
|
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||||
|
|
||||||
|
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||||
|
|
||||||
|
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||||
|
|
||||||
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||||
@@ -348,6 +371,8 @@
|
|||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||||
|
|
||||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
@@ -358,6 +383,12 @@
|
|||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||||
|
|
||||||
|
"iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
|
||||||
|
|
||||||
|
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
@@ -392,8 +423,12 @@
|
|||||||
|
|
||||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
|
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||||
|
|
||||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||||
|
|
||||||
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
||||||
@@ -406,20 +441,30 @@
|
|||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||||
|
|
||||||
|
"onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
|
||||||
|
|
||||||
|
"onnxruntime-web": ["onnxruntime-web@1.21.0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.21.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A=="],
|
||||||
|
|
||||||
"panel": ["panel@workspace:panel"],
|
"panel": ["panel@workspace:panel"],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||||
|
|
||||||
|
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||||
|
|
||||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
@@ -476,6 +521,8 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
|
"@imgly/background-removal/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@imgly/background-removal": "^1.7.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
|
|||||||
881
panel/src/pages/BackgroundRemoval.tsx
Normal file
881
panel/src/pages/BackgroundRemoval.tsx
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Upload, Download, X, Wand2, ImageIcon, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKERBOARD: React.CSSProperties = {
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
};
|
||||||
|
|
||||||
|
type BgPreset = { label: string; style: React.CSSProperties };
|
||||||
|
|
||||||
|
const BG_PRESETS: BgPreset[] = [
|
||||||
|
{ label: "Checker", style: CHECKERBOARD },
|
||||||
|
{ label: "White", style: { backgroundColor: "#ffffff" } },
|
||||||
|
{ label: "Black", style: { backgroundColor: "#000000" } },
|
||||||
|
{ label: "Red", style: { backgroundColor: "#e53e3e" } },
|
||||||
|
{ label: "Green", style: { backgroundColor: "#38a169" } },
|
||||||
|
{ label: "Blue", style: { backgroundColor: "#3182ce" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Max normalised distances for each keying space
|
||||||
|
const MAX_RGB = Math.sqrt(3); // ≈ 1.732
|
||||||
|
const MAX_HSV = 1.5; // sqrt(0.5² × 4 + 1² + 1² × 0.25)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): [number, number, number] | null {
|
||||||
|
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebGL — shaders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VERT = `
|
||||||
|
attribute vec2 aPos;
|
||||||
|
varying vec2 vUV;
|
||||||
|
void main() {
|
||||||
|
vUV = aPos * 0.5 + 0.5;
|
||||||
|
vUV.y = 1.0 - vUV.y;
|
||||||
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const KEY_FRAG = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D uImage;
|
||||||
|
uniform vec3 uKey;
|
||||||
|
uniform float uTol;
|
||||||
|
uniform float uFeather;
|
||||||
|
uniform float uSatMin;
|
||||||
|
uniform float uSpill;
|
||||||
|
uniform float uHueMode;
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
vec3 rgbToHsv(vec3 c) {
|
||||||
|
float cmax = max(c.r, max(c.g, c.b));
|
||||||
|
float cmin = min(c.r, min(c.g, c.b));
|
||||||
|
float delta = cmax - cmin;
|
||||||
|
float h = 0.0;
|
||||||
|
float s = (cmax < 0.0001) ? 0.0 : delta / cmax;
|
||||||
|
if (delta > 0.0001) {
|
||||||
|
if (cmax == c.r) h = mod((c.g - c.b) / delta, 6.0);
|
||||||
|
else if (cmax == c.g) h = (c.b - c.r) / delta + 2.0;
|
||||||
|
else h = (c.r - c.g) / delta + 4.0;
|
||||||
|
h /= 6.0;
|
||||||
|
}
|
||||||
|
return vec3(h, s, cmax);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 c = texture2D(uImage, vUV);
|
||||||
|
vec3 hsv = rgbToHsv(c.rgb);
|
||||||
|
vec3 keyHsv = rgbToHsv(uKey);
|
||||||
|
|
||||||
|
float d;
|
||||||
|
if (uHueMode > 0.5) {
|
||||||
|
float dh = abs(hsv.x - keyHsv.x);
|
||||||
|
if (dh > 0.5) dh = 1.0 - dh;
|
||||||
|
float ds = abs(hsv.y - keyHsv.y);
|
||||||
|
float dv = abs(hsv.z - keyHsv.z);
|
||||||
|
d = sqrt(dh * dh * 4.0 + ds * ds + dv * dv * 0.25);
|
||||||
|
} else {
|
||||||
|
d = distance(c.rgb, uKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
float a = c.a;
|
||||||
|
if (hsv.y >= uSatMin) {
|
||||||
|
if (d <= uTol) {
|
||||||
|
a = 0.0;
|
||||||
|
} else if (uFeather > 0.0 && d <= uTol + uFeather) {
|
||||||
|
a = (d - uTol) / uFeather * c.a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 rgb = c.rgb;
|
||||||
|
if (uSpill > 0.0) {
|
||||||
|
float edgeDist = max(0.0, d - uTol);
|
||||||
|
float spillZone = max(uFeather + uTol * 0.5, 0.01);
|
||||||
|
float spillFact = clamp(1.0 - edgeDist / spillZone, 0.0, 1.0) * uSpill;
|
||||||
|
|
||||||
|
if (uKey.g >= uKey.r && uKey.g >= uKey.b) {
|
||||||
|
float excess = rgb.g - max(rgb.r, rgb.b);
|
||||||
|
if (excess > 0.0) rgb.g -= excess * spillFact;
|
||||||
|
} else if (uKey.b >= uKey.r && uKey.b >= uKey.g) {
|
||||||
|
float excess = rgb.b - max(rgb.r, rgb.g);
|
||||||
|
if (excess > 0.0) rgb.b -= excess * spillFact;
|
||||||
|
} else {
|
||||||
|
float excess = rgb.r - max(rgb.g, rgb.b);
|
||||||
|
if (excess > 0.0) rgb.r -= excess * spillFact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(rgb, a);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const HALO_FRAG = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D uKeyed;
|
||||||
|
uniform float uHaloStr;
|
||||||
|
uniform float uHaloRadius;
|
||||||
|
uniform vec2 uTexelSize;
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 c = texture2D(uKeyed, vUV);
|
||||||
|
|
||||||
|
if (uHaloStr <= 0.0) {
|
||||||
|
gl_FragColor = c;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec2 r = uTexelSize * uHaloRadius;
|
||||||
|
vec2 rd = r * 0.7071;
|
||||||
|
|
||||||
|
float minA = c.a;
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( r.x, 0.0 )).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2(-r.x, 0.0 )).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, r.y )).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, -r.y )).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, rd.y)).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, rd.y)).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, -rd.y)).a);
|
||||||
|
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, -rd.y)).a);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(c.rgb, mix(c.a, minA, uHaloStr));
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebGL — types + init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type GlState = {
|
||||||
|
gl: WebGLRenderingContext;
|
||||||
|
kProg: WebGLProgram;
|
||||||
|
srcTex: WebGLTexture;
|
||||||
|
fbo: WebGLFramebuffer;
|
||||||
|
fboTex: WebGLTexture;
|
||||||
|
uKey: WebGLUniformLocation;
|
||||||
|
uTol: WebGLUniformLocation;
|
||||||
|
uFeather: WebGLUniformLocation;
|
||||||
|
uSatMin: WebGLUniformLocation;
|
||||||
|
uSpill: WebGLUniformLocation;
|
||||||
|
uHueMode: WebGLUniformLocation;
|
||||||
|
hProg: WebGLProgram;
|
||||||
|
uHaloStr: WebGLUniformLocation;
|
||||||
|
uHaloRadius: WebGLUniformLocation;
|
||||||
|
uTexelSize: WebGLUniformLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
function compileShader(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
|
||||||
|
const s = gl.createShader(type)!;
|
||||||
|
gl.shaderSource(s, src);
|
||||||
|
gl.compileShader(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProgram(gl: WebGLRenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram {
|
||||||
|
const p = gl.createProgram()!;
|
||||||
|
gl.attachShader(p, vs);
|
||||||
|
gl.attachShader(p, fs);
|
||||||
|
gl.bindAttribLocation(p, 0, "aPos");
|
||||||
|
gl.linkProgram(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTexture(gl: WebGLRenderingContext): WebGLTexture {
|
||||||
|
const t = gl.createTexture()!;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, t);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGl(canvas: HTMLCanvasElement): GlState | null {
|
||||||
|
const gl = canvas.getContext("webgl", {
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
}) as WebGLRenderingContext | null;
|
||||||
|
if (!gl) return null;
|
||||||
|
|
||||||
|
const vs = compileShader(gl, gl.VERTEX_SHADER, VERT);
|
||||||
|
const kFrag = compileShader(gl, gl.FRAGMENT_SHADER, KEY_FRAG);
|
||||||
|
const hFrag = compileShader(gl, gl.FRAGMENT_SHADER, HALO_FRAG);
|
||||||
|
const kProg = makeProgram(gl, vs, kFrag);
|
||||||
|
const hProg = makeProgram(gl, vs, hFrag);
|
||||||
|
|
||||||
|
const buf = gl.createBuffer()!;
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
|
||||||
|
gl.enableVertexAttribArray(0);
|
||||||
|
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const srcTex = makeTexture(gl);
|
||||||
|
const fboTex = makeTexture(gl);
|
||||||
|
|
||||||
|
const fbo = gl.createFramebuffer()!;
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||||
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTex, 0);
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gl, kProg, hProg, srcTex, fbo, fboTex,
|
||||||
|
uKey: gl.getUniformLocation(kProg, "uKey")!,
|
||||||
|
uTol: gl.getUniformLocation(kProg, "uTol")!,
|
||||||
|
uFeather: gl.getUniformLocation(kProg, "uFeather")!,
|
||||||
|
uSatMin: gl.getUniformLocation(kProg, "uSatMin")!,
|
||||||
|
uSpill: gl.getUniformLocation(kProg, "uSpill")!,
|
||||||
|
uHueMode: gl.getUniformLocation(kProg, "uHueMode")!,
|
||||||
|
uHaloStr: gl.getUniformLocation(hProg, "uHaloStr")!,
|
||||||
|
uHaloRadius: gl.getUniformLocation(hProg, "uHaloRadius")!,
|
||||||
|
uTexelSize: gl.getUniformLocation(hProg, "uTexelSize")!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI remove tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type AiStatus = "idle" | "loading" | "done" | "error";
|
||||||
|
|
||||||
|
function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
||||||
|
imageFile: File;
|
||||||
|
imageSrc: string;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState<AiStatus>("idle");
|
||||||
|
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
||||||
|
const [bgPreset, setBgPreset] = useState(0);
|
||||||
|
const [progress, setProgress] = useState("");
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
setProgress("Loading AI model…");
|
||||||
|
try {
|
||||||
|
const { removeBackground } = await import("@imgly/background-removal");
|
||||||
|
setProgress("Removing background…");
|
||||||
|
const blob = await removeBackground(imageSrc, {
|
||||||
|
progress: (_key: string, current: number, total: number) => {
|
||||||
|
if (total > 0) {
|
||||||
|
setProgress(`Downloading model… ${Math.round((current / total) * 100)}%`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setResultUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return url; });
|
||||||
|
setStatus("done");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!resultUrl) return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_nobg.png";
|
||||||
|
a.href = resultUrl;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
{status !== "done" ? (
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={status === "loading"}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
status === "loading"
|
||||||
|
? "bg-raised border border-border text-text-tertiary cursor-not-allowed"
|
||||||
|
: "bg-primary text-white hover:bg-primary/90",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status === "loading" ? (
|
||||||
|
<><Loader2 className="w-3.5 h-3.5 animate-spin" /> {progress}</>
|
||||||
|
) : (
|
||||||
|
<><Wand2 className="w-3.5 h-3.5" /> Remove Background</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Something went wrong. Check the console for details.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Side-by-side */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Original</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<img src={imageSrc} className="w-full block" alt="Original" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||||
|
Result — transparent background
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{BG_PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
title={preset.label}
|
||||||
|
onClick={() => setBgPreset(i)}
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 rounded border transition-all",
|
||||||
|
i === bgPreset
|
||||||
|
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
style={preset.style}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
{resultUrl ? (
|
||||||
|
<div style={BG_PRESETS[bgPreset].style}>
|
||||||
|
<img src={resultUrl} className="w-full block" alt="Result" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||||
|
{status === "loading" ? (
|
||||||
|
<><Loader2 className="w-10 h-10 opacity-40 animate-spin" /><span className="text-xs opacity-40 text-center">{progress}</span></>
|
||||||
|
) : (
|
||||||
|
<><ImageIcon className="w-10 h-10 opacity-20" /><span className="text-xs opacity-40 text-center">Click Remove Background to process</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BackgroundRemoval component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Mode = "chroma" | "ai";
|
||||||
|
|
||||||
|
export function BackgroundRemoval() {
|
||||||
|
const [mode, setMode] = useState<Mode>("chroma");
|
||||||
|
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const [keyColor, setKeyColor] = useState<[number, number, number] | null>(null);
|
||||||
|
const [hexInput, setHexInput] = useState("");
|
||||||
|
const [tolerance, setTolerance] = useState(30);
|
||||||
|
const [feather, setFeather] = useState(10);
|
||||||
|
const [satMin, setSatMin] = useState(0);
|
||||||
|
const [spillStr, setSpillStr] = useState(0);
|
||||||
|
const [hueMode, setHueMode] = useState(false);
|
||||||
|
const [haloStr, setHaloStr] = useState(0);
|
||||||
|
const [haloRadius, setHaloRadius] = useState(2);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [bgPreset, setBgPreset] = useState(0);
|
||||||
|
|
||||||
|
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const glCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const glRef = useRef<GlState | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (keyColor) setHexInput(rgbToHex(...keyColor));
|
||||||
|
}, [keyColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc) return;
|
||||||
|
setImageReady(false);
|
||||||
|
setKeyColor(null);
|
||||||
|
setHexInput("");
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const src = sourceCanvasRef.current;
|
||||||
|
if (!src) return;
|
||||||
|
src.width = img.naturalWidth;
|
||||||
|
src.height = img.naturalHeight;
|
||||||
|
src.getContext("2d")!.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const glCanvas = glCanvasRef.current;
|
||||||
|
if (!glCanvas) return;
|
||||||
|
glCanvas.width = img.naturalWidth;
|
||||||
|
glCanvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
if (!glRef.current) {
|
||||||
|
glRef.current = initGl(glCanvas);
|
||||||
|
if (!glRef.current) {
|
||||||
|
console.error("BackgroundRemoval: WebGL not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gl, srcTex, fboTex } = glRef.current;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, srcTex);
|
||||||
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||||
|
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||||
|
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, fboTex);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, img.naturalWidth, img.naturalHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||||
|
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
|
||||||
|
setImageReady(true);
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const state = glRef.current;
|
||||||
|
if (!state || !imageReady || !keyColor) return;
|
||||||
|
|
||||||
|
const w = glCanvasRef.current!.width;
|
||||||
|
const h = glCanvasRef.current!.height;
|
||||||
|
const { gl } = state;
|
||||||
|
const MAX = hueMode ? MAX_HSV : MAX_RGB;
|
||||||
|
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, state.fbo);
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
gl.useProgram(state.kProg);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, state.srcTex);
|
||||||
|
gl.uniform3f(state.uKey, keyColor[0] / 255, keyColor[1] / 255, keyColor[2] / 255);
|
||||||
|
gl.uniform1f(state.uTol, (tolerance / 100) * MAX);
|
||||||
|
gl.uniform1f(state.uFeather, (feather / 100) * MAX);
|
||||||
|
gl.uniform1f(state.uSatMin, satMin / 100);
|
||||||
|
gl.uniform1f(state.uSpill, spillStr / 100);
|
||||||
|
gl.uniform1f(state.uHueMode, hueMode ? 1.0 : 0.0);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
gl.useProgram(state.hProg);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, state.fboTex);
|
||||||
|
gl.uniform1f(state.uHaloStr, haloStr / 100);
|
||||||
|
gl.uniform1f(state.uHaloRadius, haloRadius);
|
||||||
|
gl.uniform2f(state.uTexelSize, 1 / w, 1 / h);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
}, [keyColor, tolerance, feather, satMin, spillStr, hueMode, haloStr, haloRadius, imageReady]);
|
||||||
|
|
||||||
|
const loadFile = useCallback((file: File) => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(URL.createObjectURL(file));
|
||||||
|
setImageFile(file);
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file?.type.startsWith("image/")) loadFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = sourceCanvasRef.current;
|
||||||
|
if (!canvas || !imageReady) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = canvas.width / rect.width;
|
||||||
|
const scaleY = canvas.height / rect.height;
|
||||||
|
const x = Math.floor((e.clientX - rect.left) * scaleX);
|
||||||
|
const y = Math.floor((e.clientY - rect.top) * scaleY);
|
||||||
|
const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data;
|
||||||
|
setKeyColor([px[0], px[1], px[2]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHexInput = (v: string) => {
|
||||||
|
setHexInput(v);
|
||||||
|
const parsed = hexToRgb(v);
|
||||||
|
if (parsed) setKeyColor(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const glCanvas = glCanvasRef.current;
|
||||||
|
if (!glCanvas || !keyColor || !imageFile) return;
|
||||||
|
glCanvas.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_transparent.png";
|
||||||
|
a.href = url;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImageReady(false);
|
||||||
|
setKeyColor(null);
|
||||||
|
setHexInput("");
|
||||||
|
glRef.current = null;
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||||
|
if (!imageSrc) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||||
|
{/* Page header + mode toggle */}
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-1">
|
||||||
|
Background Removal
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Upload an image then remove its background — either by selecting a
|
||||||
|
key color (chroma key) or using the AI model.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ModeToggle mode={mode} onChange={setMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||||
|
dragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||||
|
const hasResult = imageReady && keyColor !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-8">
|
||||||
|
{/* Page header + mode toggle */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex-1">
|
||||||
|
Background Removal
|
||||||
|
</h2>
|
||||||
|
<ModeToggle mode={mode} onChange={setMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI tab */}
|
||||||
|
{mode === "ai" && (
|
||||||
|
<AiRemoveTab imageFile={imageFile!} imageSrc={imageSrc} onClear={clearAll} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chroma key tab */}
|
||||||
|
{mode === "chroma" && (
|
||||||
|
<>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile?.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!hasResult}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
hasResult
|
||||||
|
? "bg-primary text-white hover:bg-primary/90"
|
||||||
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
||||||
|
{/* Row 1 — Key color + mode */}
|
||||||
|
<div className="flex flex-wrap gap-6 items-center">
|
||||||
|
<div className="space-y-1.5 shrink-0">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Key Color</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded-md border border-border shadow-inner shrink-0"
|
||||||
|
style={
|
||||||
|
keyColor
|
||||||
|
? { backgroundColor: rgbToHex(...keyColor) }
|
||||||
|
: {
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(45deg,#444 25%,transparent 25%),linear-gradient(-45deg,#444 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#444 75%),linear-gradient(-45deg,transparent 75%,#444 75%)",
|
||||||
|
backgroundSize: "8px 8px",
|
||||||
|
backgroundPosition: "0 0,0 4px,4px -4px,-4px 0",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hexInput}
|
||||||
|
onChange={(e) => handleHexInput(e.target.value)}
|
||||||
|
placeholder="#rrggbb"
|
||||||
|
maxLength={7}
|
||||||
|
spellCheck={false}
|
||||||
|
className={cn(
|
||||||
|
"w-[76px] text-xs font-mono bg-transparent border rounded px-1.5 py-0.5",
|
||||||
|
"text-text-secondary focus:outline-none transition-colors",
|
||||||
|
hexInput && !hexToRgb(hexInput)
|
||||||
|
? "border-destructive focus:border-destructive"
|
||||||
|
: "border-border focus:border-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 shrink-0">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Keying Mode</p>
|
||||||
|
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setHueMode(false)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 transition-colors",
|
||||||
|
!hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
RGB
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setHueMode(true)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 transition-colors border-l border-border",
|
||||||
|
hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
HSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!keyColor && (
|
||||||
|
<span className="text-xs text-text-tertiary flex items-center gap-1.5 ml-auto">
|
||||||
|
<Wand2 className="w-3.5 h-3.5" /> Click the image to pick a key color
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2 — Matte */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Matte</p>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Tolerance</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{tolerance}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value={tolerance} onChange={(e) => setTolerance(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Sat. Gate</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{satMin}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value={satMin} onChange={(e) => setSatMin(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
<p className="text-[10px] text-text-tertiary leading-tight">Skip pixels below this saturation — preserves neutral tones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Edge Feather</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{feather}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="50" value={feather} onChange={(e) => setFeather(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3 — Cleanup */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Cleanup</p>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Despill</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{spillStr}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value={spillStr} onChange={(e) => setSpillStr(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
<p className="text-[10px] text-text-tertiary leading-tight">Suppress key-color fringing on subject edges</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Halo Remove</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{haloStr}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value={haloStr} onChange={(e) => setHaloStr(Number(e.target.value))} className="w-full accent-primary" />
|
||||||
|
<p className="text-[10px] text-text-tertiary leading-tight">Erode the matte inward to eliminate bright rim pixels</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Halo Radius</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary">{haloRadius} px</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="1" max="8" step="1" value={haloRadius} onChange={(e) => setHaloRadius(Number(e.target.value))} className="w-full accent-primary" disabled={haloStr === 0} />
|
||||||
|
<p className="text-[10px] text-text-tertiary leading-tight">How far to look for transparent neighbours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side-by-side view */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Original — click to pick key color
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<canvas ref={sourceCanvasRef} className="w-full cursor-crosshair block" onClick={handleCanvasClick} title="Click a pixel to set it as the chroma key color" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||||
|
Result — transparent background
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{BG_PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
title={preset.label}
|
||||||
|
onClick={() => setBgPreset(i)}
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 rounded border transition-all",
|
||||||
|
i === bgPreset
|
||||||
|
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
style={preset.style}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !hasResult && "hidden")}>
|
||||||
|
<canvas ref={glCanvasRef} className="w-full block" />
|
||||||
|
</div>
|
||||||
|
{!hasResult && (
|
||||||
|
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||||
|
<ImageIcon className="w-10 h-10 opacity-20" />
|
||||||
|
<span className="text-xs opacity-40 text-center leading-relaxed">
|
||||||
|
{imageReady ? "Click the image on the left to pick a key color" : "Loading image…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ModeToggle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange("chroma")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 transition-colors",
|
||||||
|
mode === "chroma" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Chroma Key
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange("ai")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 transition-colors border-l border-border",
|
||||||
|
mode === "ai" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
AI Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
509
panel/src/pages/CanvasTool.tsx
Normal file
509
panel/src/pages/CanvasTool.tsx
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Upload, Download, X, Lock, Unlock, ImageIcon, Maximize2 } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKERBOARD: React.CSSProperties = {
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
};
|
||||||
|
|
||||||
|
type BgPreset = { label: string; style: React.CSSProperties };
|
||||||
|
|
||||||
|
const BG_PRESETS: BgPreset[] = [
|
||||||
|
{ label: "Checker", style: CHECKERBOARD },
|
||||||
|
{ label: "White", style: { backgroundColor: "#ffffff" } },
|
||||||
|
{ label: "Black", style: { backgroundColor: "#000000" } },
|
||||||
|
{ label: "Red", style: { backgroundColor: "#e53e3e" } },
|
||||||
|
{ label: "Green", style: { backgroundColor: "#38a169" } },
|
||||||
|
{ label: "Blue", style: { backgroundColor: "#3182ce" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ScaleMode = "fit" | "fill" | "stretch" | "original";
|
||||||
|
|
||||||
|
const SCALE_MODES: { id: ScaleMode; label: string; desc: string }[] = [
|
||||||
|
{ id: "fit", label: "Fit", desc: "Scale to fit canvas, preserve ratio (letterbox)" },
|
||||||
|
{ id: "fill", label: "Fill", desc: "Scale to fill canvas, preserve ratio (crop edges)" },
|
||||||
|
{ id: "stretch", label: "Stretch", desc: "Stretch to exact dimensions, ignore ratio" },
|
||||||
|
{ id: "original", label: "Original", desc: "No scaling — align/center only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Align = [-1 | 0 | 1, -1 | 0 | 1];
|
||||||
|
|
||||||
|
const ALIGN_GRID: Align[] = [
|
||||||
|
[-1, -1], [0, -1], [1, -1],
|
||||||
|
[-1, 0], [0, 0], [1, 0],
|
||||||
|
[-1, 1], [0, 1], [1, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIZE_PRESETS: { label: string; w: number; h: number }[] = [
|
||||||
|
{ label: "64", w: 64, h: 64 },
|
||||||
|
{ label: "128", w: 128, h: 128 },
|
||||||
|
{ label: "256", w: 256, h: 256 },
|
||||||
|
{ label: "512", w: 512, h: 512 },
|
||||||
|
{ label: "1024", w: 1024, h: 1024 },
|
||||||
|
{ label: "960×540", w: 960, h: 540 },
|
||||||
|
{ label: "1920×1080", w: 1920, h: 1080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CanvasTool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function CanvasTool() {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const [naturalW, setNaturalW] = useState(1);
|
||||||
|
const [naturalH, setNaturalH] = useState(1);
|
||||||
|
|
||||||
|
const [outW, setOutW] = useState("256");
|
||||||
|
const [outH, setOutH] = useState("256");
|
||||||
|
const [aspectLock, setAspectLock] = useState(true);
|
||||||
|
const [scaleMode, setScaleMode] = useState<ScaleMode>("fit");
|
||||||
|
const [alignment, setAlignment] = useState<Align>([0, 0]);
|
||||||
|
const [bgPreset, setBgPreset] = useState(0);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// previewCanvasRef — drawn to directly on every setting change; no toDataURL
|
||||||
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
// ── Load image onto hidden source canvas ──────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc) return;
|
||||||
|
setImageReady(false);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = sourceCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
canvas.getContext("2d")!.drawImage(img, 0, 0);
|
||||||
|
setNaturalW(img.naturalWidth);
|
||||||
|
setNaturalH(img.naturalHeight);
|
||||||
|
setOutW(String(img.naturalWidth));
|
||||||
|
setOutH(String(img.naturalHeight));
|
||||||
|
setImageReady(true);
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Draw output directly into previewCanvasRef whenever settings change ────
|
||||||
|
// drawImage is GPU-accelerated; skipping toDataURL eliminates the main bottleneck.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageReady) return;
|
||||||
|
const src = sourceCanvasRef.current;
|
||||||
|
const prev = previewCanvasRef.current;
|
||||||
|
if (!src || !prev) return;
|
||||||
|
|
||||||
|
const w = parseInt(outW);
|
||||||
|
const h = parseInt(outH);
|
||||||
|
if (!w || !h || w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
prev.width = w;
|
||||||
|
prev.height = h;
|
||||||
|
const ctx = prev.getContext("2d")!;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const srcW = src.width;
|
||||||
|
const srcH = src.height;
|
||||||
|
|
||||||
|
let drawW: number;
|
||||||
|
let drawH: number;
|
||||||
|
|
||||||
|
if (scaleMode === "fit") {
|
||||||
|
const scale = Math.min(w / srcW, h / srcH);
|
||||||
|
drawW = srcW * scale;
|
||||||
|
drawH = srcH * scale;
|
||||||
|
} else if (scaleMode === "fill") {
|
||||||
|
const scale = Math.max(w / srcW, h / srcH);
|
||||||
|
drawW = srcW * scale;
|
||||||
|
drawH = srcH * scale;
|
||||||
|
} else if (scaleMode === "stretch") {
|
||||||
|
drawW = w;
|
||||||
|
drawH = h;
|
||||||
|
} else {
|
||||||
|
drawW = srcW;
|
||||||
|
drawH = srcH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = (w - drawW) * (alignment[0] + 1) / 2;
|
||||||
|
const y = (h - drawH) * (alignment[1] + 1) / 2;
|
||||||
|
|
||||||
|
ctx.drawImage(src, x, y, drawW, drawH);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [imageReady, outW, outH, scaleMode, alignment]);
|
||||||
|
|
||||||
|
// ── Width / height with optional aspect lock ───────────────────────────────
|
||||||
|
const handleWChange = (v: string) => {
|
||||||
|
setOutW(v);
|
||||||
|
if (aspectLock) {
|
||||||
|
const n = parseInt(v);
|
||||||
|
if (!isNaN(n) && n > 0) setOutH(String(Math.round(n * naturalH / naturalW)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHChange = (v: string) => {
|
||||||
|
setOutH(v);
|
||||||
|
if (aspectLock) {
|
||||||
|
const n = parseInt(v);
|
||||||
|
if (!isNaN(n) && n > 0) setOutW(String(Math.round(n * naturalW / naturalH)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── File loading ───────────────────────────────────────────────────────────
|
||||||
|
const loadFile = useCallback((file: File) => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(URL.createObjectURL(file));
|
||||||
|
setImageFile(file);
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file?.type.startsWith("image/")) loadFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download: toBlob from previewCanvasRef — only at explicit user request
|
||||||
|
const handleDownload = () => {
|
||||||
|
const prev = previewCanvasRef.current;
|
||||||
|
if (!prev || !imageReady || !imageFile) return;
|
||||||
|
prev.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + `_${outW}x${outH}.png`;
|
||||||
|
a.href = url;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImageReady(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||||
|
if (!imageSrc) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-1">Canvas Tool</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Upload an image to resize, scale, or center it on a canvas of any size.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||||
|
dragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||||
|
const alignDisabled = scaleMode === "stretch";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-8">
|
||||||
|
{/* Hidden source canvas */}
|
||||||
|
<canvas ref={sourceCanvasRef} className="hidden" />
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex-1">Canvas Tool</h2>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile?.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono">
|
||||||
|
{naturalW}×{naturalH}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!imageReady}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
imageReady
|
||||||
|
? "bg-primary text-white hover:bg-primary/90"
|
||||||
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
||||||
|
{/* Row 1: output size */}
|
||||||
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Output Size</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number" min="1" max="4096" value={outW}
|
||||||
|
onChange={(e) => handleWChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
placeholder="W"
|
||||||
|
/>
|
||||||
|
<span className="text-text-tertiary text-xs">×</span>
|
||||||
|
<input
|
||||||
|
type="number" min="1" max="4096" value={outH}
|
||||||
|
onChange={(e) => handleHChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
placeholder="H"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setAspectLock((l) => !l)}
|
||||||
|
title={aspectLock ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md border transition-colors",
|
||||||
|
aspectLock
|
||||||
|
? "border-primary text-primary bg-primary/10"
|
||||||
|
: "border-border text-text-tertiary hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{aspectLock ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size presets */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Presets</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setOutW(String(naturalW)); setOutH(String(naturalH)); }}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded text-xs border transition-colors",
|
||||||
|
outW === String(naturalW) && outH === String(naturalH)
|
||||||
|
? "border-primary text-primary bg-primary/10"
|
||||||
|
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Original
|
||||||
|
</button>
|
||||||
|
{SIZE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (aspectLock) {
|
||||||
|
const scale = Math.min(p.w / naturalW, p.h / naturalH);
|
||||||
|
setOutW(String(Math.round(naturalW * scale)));
|
||||||
|
setOutH(String(Math.round(naturalH * scale)));
|
||||||
|
} else {
|
||||||
|
setOutW(String(p.w));
|
||||||
|
setOutH(String(p.h));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded text-xs border transition-colors",
|
||||||
|
outW === String(p.w) && outH === String(p.h)
|
||||||
|
? "border-primary text-primary bg-primary/10"
|
||||||
|
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: scale mode + alignment */}
|
||||||
|
<div className="flex flex-wrap gap-6 items-start">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">Scale Mode</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{SCALE_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setScaleMode(m.id)}
|
||||||
|
title={m.desc}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-md text-xs font-medium border transition-colors",
|
||||||
|
scaleMode === m.id
|
||||||
|
? "border-primary text-primary bg-primary/10"
|
||||||
|
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
{SCALE_MODES.find((m) => m.id === scaleMode)?.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("space-y-1.5", alignDisabled && "opacity-40 pointer-events-none")}>
|
||||||
|
<p className="text-xs font-medium text-text-secondary">
|
||||||
|
{scaleMode === "fill" ? "Crop Position" : "Alignment"}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-0.5 w-fit">
|
||||||
|
{ALIGN_GRID.map(([col, row], i) => {
|
||||||
|
const active = alignment[0] === col && alignment[1] === row;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setAlignment([col, row])}
|
||||||
|
title={`${row === -1 ? "Top" : row === 0 ? "Middle" : "Bottom"}-${col === -1 ? "Left" : col === 0 ? "Center" : "Right"}`}
|
||||||
|
className={cn(
|
||||||
|
"w-7 h-7 rounded flex items-center justify-center border transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary/20"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"w-2 h-2 rounded-full transition-colors",
|
||||||
|
active ? "bg-primary" : "bg-text-tertiary/40",
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side-by-side preview */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Original */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Original — {naturalW}×{naturalH}
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div style={CHECKERBOARD}>
|
||||||
|
<img src={imageSrc} alt="Source" className="w-full block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result — canvas drawn directly, no toDataURL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||||
|
Result — {outW || "?"}×{outH || "?"}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{BG_PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
title={preset.label}
|
||||||
|
onClick={() => setBgPreset(i)}
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 rounded border transition-all",
|
||||||
|
i === bgPreset
|
||||||
|
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
style={preset.style}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}>
|
||||||
|
<canvas ref={previewCanvasRef} className="w-full block" />
|
||||||
|
</div>
|
||||||
|
{!imageReady && (
|
||||||
|
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||||
|
<ImageIcon className="w-10 h-10 opacity-20" />
|
||||||
|
<span className="text-xs opacity-40 text-center leading-relaxed">
|
||||||
|
Loading image…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info strip */}
|
||||||
|
{imageReady && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
||||||
|
<Maximize2 className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Output: <span className="font-mono text-foreground">{outW}×{outH} px</span>
|
||||||
|
{" · "}
|
||||||
|
Mode: <span className="text-foreground capitalize">{scaleMode}</span>
|
||||||
|
{scaleMode !== "stretch" && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
Align:{" "}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{alignment[1] === -1 ? "Top" : alignment[1] === 0 ? "Middle" : "Bottom"}
|
||||||
|
-{alignment[0] === -1 ? "Left" : alignment[0] === 0 ? "Center" : "Right"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
653
panel/src/pages/CropTool.tsx
Normal file
653
panel/src/pages/CropTool.tsx
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Upload, Download, X, ImageIcon, Crosshair, Maximize2 } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKERBOARD: React.CSSProperties = {
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extra space around the output rect in the editor so you can see the image
|
||||||
|
// when it extends (or will be panned) outside the output boundaries.
|
||||||
|
const PAD = 120;
|
||||||
|
const HANDLE_HIT = 8;
|
||||||
|
const MIN_CROP = 4;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module-level checkerboard tile cache
|
||||||
|
// Avoids recreating a canvas element on every drawEditor call.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _checkerTile: HTMLCanvasElement | null = null;
|
||||||
|
|
||||||
|
function checkerTile(): HTMLCanvasElement {
|
||||||
|
if (_checkerTile) return _checkerTile;
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = 16; c.height = 16;
|
||||||
|
const ctx = c.getContext("2d")!;
|
||||||
|
ctx.fillStyle = "#2a2a2a"; ctx.fillRect(0, 0, 16, 16);
|
||||||
|
ctx.fillStyle = "#3d3d3d"; ctx.fillRect(0, 0, 8, 8); ctx.fillRect(8, 8, 8, 8);
|
||||||
|
_checkerTile = c;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// "pan" = drag inside output rect to move the image
|
||||||
|
type DragHandle =
|
||||||
|
| "nw" | "n" | "ne"
|
||||||
|
| "w" | "e"
|
||||||
|
| "sw" | "s" | "se"
|
||||||
|
| "pan";
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
handle: DragHandle;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
origW: number;
|
||||||
|
origH: number;
|
||||||
|
origImgX: number;
|
||||||
|
origImgY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getHandlePoints(
|
||||||
|
cx0: number, cy0: number, cx1: number, cy1: number
|
||||||
|
): [DragHandle, number, number][] {
|
||||||
|
const mx = (cx0 + cx1) / 2;
|
||||||
|
const my = (cy0 + cy1) / 2;
|
||||||
|
return [
|
||||||
|
["nw", cx0, cy0], ["n", mx, cy0], ["ne", cx1, cy0],
|
||||||
|
["w", cx0, my], ["e", cx1, my],
|
||||||
|
["sw", cx0, cy1], ["s", mx, cy1], ["se", cx1, cy1],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitHandle(
|
||||||
|
px: number, py: number,
|
||||||
|
cx0: number, cy0: number, cx1: number, cy1: number
|
||||||
|
): DragHandle | null {
|
||||||
|
for (const [name, hx, hy] of getHandlePoints(cx0, cy0, cx1, cy1)) {
|
||||||
|
if (Math.abs(px - hx) <= HANDLE_HIT && Math.abs(py - hy) <= HANDLE_HIT) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (px >= cx0 && px <= cx1 && py >= cy0 && py <= cy1) return "pan";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCursor(h: DragHandle | null, dragging = false): string {
|
||||||
|
if (!h) return "default";
|
||||||
|
if (h === "pan") return dragging ? "grabbing" : "grab";
|
||||||
|
if (h === "nw" || h === "se") return "nwse-resize";
|
||||||
|
if (h === "ne" || h === "sw") return "nesw-resize";
|
||||||
|
if (h === "n" || h === "s") return "ns-resize";
|
||||||
|
return "ew-resize";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// drawEditor
|
||||||
|
//
|
||||||
|
// Mental model: the output canvas (cropW × cropH) is pinned at (PAD, PAD).
|
||||||
|
// The source image sits at (PAD + imgX, PAD + imgY) and can extend outside
|
||||||
|
// the output in any direction. Areas inside the output not covered by the
|
||||||
|
// image are transparent (shown as checkerboard).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function drawEditor(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
img: HTMLImageElement,
|
||||||
|
imgW: number, imgH: number,
|
||||||
|
cropW: number, cropH: number,
|
||||||
|
imgX: number, imgY: number,
|
||||||
|
) {
|
||||||
|
const cW = Math.max(cropW, imgW) + 2 * PAD;
|
||||||
|
const cH = Math.max(cropH, imgH) + 2 * PAD;
|
||||||
|
if (canvas.width !== cW || canvas.height !== cH) {
|
||||||
|
canvas.width = cW;
|
||||||
|
canvas.height = cH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
// 1. Checkerboard background — use cached tile to avoid createElement every frame
|
||||||
|
ctx.fillStyle = ctx.createPattern(checkerTile(), "repeat")!;
|
||||||
|
ctx.fillRect(0, 0, cW, cH);
|
||||||
|
|
||||||
|
// 2. Faint image boundary indicator (dashed border around full source image)
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.strokeRect(PAD + imgX + 0.5, PAD + imgY + 0.5, imgW - 1, imgH - 1);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 3. Draw source image
|
||||||
|
ctx.drawImage(img, PAD + imgX, PAD + imgY);
|
||||||
|
|
||||||
|
// 4. Dark overlay outside the output rect
|
||||||
|
const ox0 = PAD, oy0 = PAD, ox1 = PAD + cropW, oy1 = PAD + cropH;
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
||||||
|
ctx.fillRect(0, 0, cW, oy0); // top
|
||||||
|
ctx.fillRect(0, oy1, cW, cH - oy1); // bottom
|
||||||
|
ctx.fillRect(0, oy0, ox0, oy1 - oy0); // left
|
||||||
|
ctx.fillRect(ox1, oy0, cW - ox1, oy1 - oy0); // right
|
||||||
|
|
||||||
|
// 5. Rule-of-thirds grid
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.18)";
|
||||||
|
ctx.lineWidth = 0.75;
|
||||||
|
for (const t of [1 / 3, 2 / 3]) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(ox0 + cropW * t, oy0); ctx.lineTo(ox0 + cropW * t, oy1); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(ox0, oy0 + cropH * t); ctx.lineTo(ox1, oy0 + cropH * t); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Output rect border
|
||||||
|
ctx.strokeStyle = "#fff";
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([5, 3]);
|
||||||
|
ctx.strokeRect(ox0 + 0.5, oy0 + 0.5, cropW - 1, cropH - 1);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// 7. Resize handles
|
||||||
|
ctx.fillStyle = "#fff";
|
||||||
|
ctx.strokeStyle = "#555";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (const [, hx, hy] of getHandlePoints(ox0, oy0, ox1, oy1)) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(hx - 4, hy - 4, 8, 8);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CropTool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function CropTool() {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imgW, setImgW] = useState(0);
|
||||||
|
const [imgH, setImgH] = useState(0);
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
// Output canvas dimensions
|
||||||
|
const [cropW, setCropW] = useState(128);
|
||||||
|
const [cropH, setCropH] = useState(128);
|
||||||
|
|
||||||
|
// Where the source image's top-left sits within the output canvas.
|
||||||
|
const [imgX, setImgX] = useState(0);
|
||||||
|
const [imgY, setImgY] = useState(0);
|
||||||
|
|
||||||
|
// Padding used by "Fit to Content"
|
||||||
|
const [padding, setPadding] = useState(0);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const displayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// Direct canvas preview — avoids toDataURL on every drag frame
|
||||||
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const imgElRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const dragStateRef = useRef<DragState | null>(null);
|
||||||
|
|
||||||
|
// Always-fresh values for use inside stable callbacks
|
||||||
|
const outputRef = useRef({ w: cropW, h: cropH, imgX, imgY, padding });
|
||||||
|
useEffect(() => { outputRef.current = { w: cropW, h: cropH, imgX, imgY, padding }; });
|
||||||
|
|
||||||
|
// ── Load image onto hidden source canvas ────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc) return;
|
||||||
|
setImageReady(false);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const src = sourceCanvasRef.current!;
|
||||||
|
src.width = img.naturalWidth;
|
||||||
|
src.height = img.naturalHeight;
|
||||||
|
src.getContext("2d")!.drawImage(img, 0, 0);
|
||||||
|
imgElRef.current = img;
|
||||||
|
setImgW(img.naturalWidth);
|
||||||
|
setImgH(img.naturalHeight);
|
||||||
|
setCropW(img.naturalWidth);
|
||||||
|
setCropH(img.naturalHeight);
|
||||||
|
setImgX(0);
|
||||||
|
setImgY(0);
|
||||||
|
setImageReady(true);
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Redraw editor whenever state changes ─────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageReady || !displayCanvasRef.current || !imgElRef.current) return;
|
||||||
|
drawEditor(displayCanvasRef.current, imgElRef.current, imgW, imgH, cropW, cropH, imgX, imgY);
|
||||||
|
}, [imageReady, imgW, imgH, cropW, cropH, imgX, imgY]);
|
||||||
|
|
||||||
|
// ── Live preview — draw directly into previewCanvasRef (no toDataURL) ────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageReady || !previewCanvasRef.current) return;
|
||||||
|
const src = sourceCanvasRef.current!;
|
||||||
|
const prev = previewCanvasRef.current;
|
||||||
|
prev.width = Math.max(1, Math.round(cropW));
|
||||||
|
prev.height = Math.max(1, Math.round(cropH));
|
||||||
|
// Clear to transparent, then composite the cropped region
|
||||||
|
const ctx = prev.getContext("2d")!;
|
||||||
|
ctx.clearRect(0, 0, prev.width, prev.height);
|
||||||
|
ctx.drawImage(src, Math.round(imgX), Math.round(imgY));
|
||||||
|
}, [imageReady, cropW, cropH, imgX, imgY]);
|
||||||
|
|
||||||
|
// ── Auto-center ──────────────────────────────────────────────────────────────
|
||||||
|
const autoCenter = useCallback(() => {
|
||||||
|
if (!imageReady) return;
|
||||||
|
const src = sourceCanvasRef.current!;
|
||||||
|
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
|
||||||
|
|
||||||
|
let minX = width, minY = height, maxX = -1, maxY = -1;
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (data[(y * width + x) * 4 + 3] > 0) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxX === -1) return;
|
||||||
|
|
||||||
|
const contentCX = (minX + maxX) / 2;
|
||||||
|
const contentCY = (minY + maxY) / 2;
|
||||||
|
const { w, h } = outputRef.current;
|
||||||
|
setImgX(Math.round(w / 2 - contentCX));
|
||||||
|
setImgY(Math.round(h / 2 - contentCY));
|
||||||
|
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Fit to content ───────────────────────────────────────────────────────────
|
||||||
|
const fitToContent = useCallback(() => {
|
||||||
|
if (!imageReady) return;
|
||||||
|
const src = sourceCanvasRef.current!;
|
||||||
|
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
|
||||||
|
|
||||||
|
let minX = width, minY = height, maxX = -1, maxY = -1;
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (data[(y * width + x) * 4 + 3] > 0) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxX === -1) return;
|
||||||
|
|
||||||
|
const pad = outputRef.current.padding;
|
||||||
|
const contentW = maxX - minX + 1;
|
||||||
|
const contentH = maxY - minY + 1;
|
||||||
|
setCropW(contentW + 2 * pad);
|
||||||
|
setCropH(contentH + 2 * pad);
|
||||||
|
setImgX(pad - minX);
|
||||||
|
setImgY(pad - minY);
|
||||||
|
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── File loading ─────────────────────────────────────────────────────────────
|
||||||
|
const loadFile = useCallback((file: File) => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(URL.createObjectURL(file));
|
||||||
|
setImageFile(file);
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImageReady(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// Download: toBlob from previewCanvasRef — only at explicit user request
|
||||||
|
const handleDownload = () => {
|
||||||
|
const prev = previewCanvasRef.current;
|
||||||
|
if (!prev || !imageReady || !imageFile) return;
|
||||||
|
prev.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_cropped.png";
|
||||||
|
a.href = url;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Canvas interaction ───────────────────────────────────────────────────────
|
||||||
|
const getCanvasXY = (e: React.MouseEvent<HTMLCanvasElement>): [number, number] => {
|
||||||
|
const canvas = displayCanvasRef.current!;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return [
|
||||||
|
(e.clientX - rect.left) * (canvas.width / rect.width),
|
||||||
|
(e.clientY - rect.top) * (canvas.height / rect.height),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const [px, py] = getCanvasXY(e);
|
||||||
|
const { w, h, imgX: ix, imgY: iy } = outputRef.current;
|
||||||
|
const handle = hitHandle(px, py, PAD, PAD, PAD + w, PAD + h);
|
||||||
|
if (!handle) return;
|
||||||
|
dragStateRef.current = {
|
||||||
|
handle,
|
||||||
|
startX: px, startY: py,
|
||||||
|
origW: w, origH: h,
|
||||||
|
origImgX: ix, origImgY: iy,
|
||||||
|
};
|
||||||
|
if (displayCanvasRef.current) {
|
||||||
|
displayCanvasRef.current.style.cursor = handleCursor(handle, true);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const onMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = displayCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const [px, py] = getCanvasXY(e);
|
||||||
|
|
||||||
|
if (!dragStateRef.current) {
|
||||||
|
const { w, h } = outputRef.current;
|
||||||
|
canvas.style.cursor = handleCursor(hitHandle(px, py, PAD, PAD, PAD + w, PAD + h));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handle, startX, startY, origW, origH, origImgX, origImgY } = dragStateRef.current;
|
||||||
|
const dx = Math.round(px - startX);
|
||||||
|
const dy = Math.round(py - startY);
|
||||||
|
|
||||||
|
let nw = origW, nh = origH, nix = origImgX, niy = origImgY;
|
||||||
|
|
||||||
|
if (handle === "pan") {
|
||||||
|
nix = origImgX + dx;
|
||||||
|
niy = origImgY + dy;
|
||||||
|
} else {
|
||||||
|
if (handle.includes("e")) nw = Math.max(MIN_CROP, origW + dx);
|
||||||
|
if (handle.includes("s")) nh = Math.max(MIN_CROP, origH + dy);
|
||||||
|
if (handle.includes("w")) {
|
||||||
|
const d = Math.min(dx, origW - MIN_CROP);
|
||||||
|
nw = origW - d;
|
||||||
|
nix = origImgX - d;
|
||||||
|
}
|
||||||
|
if (handle.includes("n")) {
|
||||||
|
const d = Math.min(dy, origH - MIN_CROP);
|
||||||
|
nh = origH - d;
|
||||||
|
niy = origImgY - d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCropW(nw);
|
||||||
|
setCropH(nh);
|
||||||
|
setImgX(nix);
|
||||||
|
setImgY(niy);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const onMouseUp = useCallback(() => {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
if (displayCanvasRef.current) displayCanvasRef.current.style.cursor = "default";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseIntSafe = (v: string, fallback: number) => {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return isNaN(n) ? fallback : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Upload screen ────────────────────────────────────────────────────────────
|
||||||
|
if (!imageSrc) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-1">Crop Tool</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Upload an image, then define an output canvas. Drag inside the canvas
|
||||||
|
to pan the image within it, or drag the edge handles to resize. The
|
||||||
|
output canvas can extend beyond the image to add transparent padding.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (f?.type.startsWith("image/")) loadFile(f);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||||
|
dragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor screen ────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-8">
|
||||||
|
<canvas ref={sourceCanvasRef} className="hidden" />
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex-1">Crop Tool</h2>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile?.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!imageReady}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
imageReady
|
||||||
|
? "bg-primary text-white hover:bg-primary/90"
|
||||||
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Output W (px)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={MIN_CROP}
|
||||||
|
value={cropW}
|
||||||
|
onChange={(e) => setCropW(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
||||||
|
className={cn(
|
||||||
|
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Output H (px)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={MIN_CROP}
|
||||||
|
value={cropH}
|
||||||
|
onChange={(e) => setCropH(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
||||||
|
className={cn(
|
||||||
|
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Image X</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={imgX}
|
||||||
|
onChange={(e) => setImgX(parseIntSafe(e.target.value, 0))}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Image Y</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={imgY}
|
||||||
|
onChange={(e) => setImgY(parseIntSafe(e.target.value, 0))}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-text-tertiary self-center font-mono pb-1">
|
||||||
|
src: {imgW} × {imgH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1 border-t border-border">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-text-secondary block">Padding (px)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={padding}
|
||||||
|
onChange={(e) => setPadding(Math.max(0, parseIntSafe(e.target.value, 0)))}
|
||||||
|
className={cn(
|
||||||
|
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 self-end pb-0.5">
|
||||||
|
<button
|
||||||
|
onClick={autoCenter}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
|
||||||
|
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
||||||
|
)}
|
||||||
|
title="Pan the image so non-transparent content is centered within the current output canvas"
|
||||||
|
>
|
||||||
|
<Crosshair className="w-3.5 h-3.5" /> Auto-center
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={fitToContent}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
|
||||||
|
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
||||||
|
)}
|
||||||
|
title="Resize output to the non-transparent content bounding box + padding, then center"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5" /> Fit to Content
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary self-end pb-1 ml-auto">
|
||||||
|
Drag inside the canvas to pan · drag handles to resize
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor + Preview */}
|
||||||
|
<div className="grid grid-cols-[1fr_260px] gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Editor
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="bg-card border border-border rounded-lg overflow-auto"
|
||||||
|
style={{ maxHeight: "62vh" }}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={displayCanvasRef}
|
||||||
|
className="block"
|
||||||
|
style={{ maxWidth: "100%" }}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
onMouseLeave={onMouseUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Preview
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
{imageReady ? (
|
||||||
|
<div style={CHECKERBOARD}>
|
||||||
|
<canvas
|
||||||
|
ref={previewCanvasRef}
|
||||||
|
className="w-full block"
|
||||||
|
style={{ imageRendering: (cropW < 64 || cropH < 64) ? "pixelated" : "auto" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-square flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-10 h-10 text-text-tertiary opacity-20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{imageReady && (
|
||||||
|
<p className="text-xs text-text-tertiary text-center font-mono">
|
||||||
|
{Math.round(cropW)} × {Math.round(cropH)} px
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
470
panel/src/pages/HueShifter.tsx
Normal file
470
panel/src/pages/HueShifter.tsx
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Upload, Download, X, ImageIcon } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKERBOARD: React.CSSProperties = {
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||||
|
`,
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WebGL — shaders + init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VERT = `
|
||||||
|
attribute vec2 aPos;
|
||||||
|
varying vec2 vUV;
|
||||||
|
void main() {
|
||||||
|
vUV = aPos * 0.5 + 0.5;
|
||||||
|
vUV.y = 1.0 - vUV.y;
|
||||||
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// RGB↔HSL math in GLSL — runs massively parallel on GPU
|
||||||
|
const FRAG = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D uImage;
|
||||||
|
uniform float uHueShift;
|
||||||
|
uniform float uSaturation;
|
||||||
|
uniform float uLightness;
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
vec3 rgb2hsl(vec3 c) {
|
||||||
|
float maxC = max(c.r, max(c.g, c.b));
|
||||||
|
float minC = min(c.r, min(c.g, c.b));
|
||||||
|
float l = (maxC + minC) * 0.5;
|
||||||
|
float d = maxC - minC;
|
||||||
|
if (d < 0.001) return vec3(0.0, 0.0, l);
|
||||||
|
float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
|
||||||
|
float h;
|
||||||
|
if (maxC == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||||
|
else if (maxC == c.g) h = (c.b - c.r) / d + 2.0;
|
||||||
|
else h = (c.r - c.g) / d + 4.0;
|
||||||
|
return vec3(h / 6.0, s, l);
|
||||||
|
}
|
||||||
|
|
||||||
|
float hue2rgb(float p, float q, float t) {
|
||||||
|
if (t < 0.0) t += 1.0;
|
||||||
|
if (t > 1.0) t -= 1.0;
|
||||||
|
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||||
|
if (t < 0.5) return q;
|
||||||
|
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 hsl2rgb(vec3 hsl) {
|
||||||
|
float h = hsl.x, s = hsl.y, l = hsl.z;
|
||||||
|
if (s < 0.001) return vec3(l);
|
||||||
|
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
|
||||||
|
float p = 2.0 * l - q;
|
||||||
|
return vec3(
|
||||||
|
hue2rgb(p, q, h + 1.0 / 3.0),
|
||||||
|
hue2rgb(p, q, h),
|
||||||
|
hue2rgb(p, q, h - 1.0 / 3.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 c = texture2D(uImage, vUV);
|
||||||
|
if (c.a < 0.001) { gl_FragColor = c; return; }
|
||||||
|
vec3 hsl = rgb2hsl(c.rgb);
|
||||||
|
hsl.x = fract(hsl.x + uHueShift + 1.0);
|
||||||
|
hsl.y = clamp(hsl.y + uSaturation, 0.0, 1.0);
|
||||||
|
hsl.z = clamp(hsl.z + uLightness, 0.0, 1.0);
|
||||||
|
gl_FragColor = vec4(hsl2rgb(hsl), c.a);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
type GlState = {
|
||||||
|
gl: WebGLRenderingContext;
|
||||||
|
tex: WebGLTexture;
|
||||||
|
uHueShift: WebGLUniformLocation;
|
||||||
|
uSaturation: WebGLUniformLocation;
|
||||||
|
uLightness: WebGLUniformLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
function initGl(canvas: HTMLCanvasElement): GlState | null {
|
||||||
|
const gl = canvas.getContext("webgl", {
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
}) as WebGLRenderingContext | null;
|
||||||
|
if (!gl) return null;
|
||||||
|
|
||||||
|
const vert = gl.createShader(gl.VERTEX_SHADER)!;
|
||||||
|
gl.shaderSource(vert, VERT);
|
||||||
|
gl.compileShader(vert);
|
||||||
|
|
||||||
|
const frag = gl.createShader(gl.FRAGMENT_SHADER)!;
|
||||||
|
gl.shaderSource(frag, FRAG);
|
||||||
|
gl.compileShader(frag);
|
||||||
|
|
||||||
|
const prog = gl.createProgram()!;
|
||||||
|
gl.attachShader(prog, vert);
|
||||||
|
gl.attachShader(prog, frag);
|
||||||
|
gl.linkProgram(prog);
|
||||||
|
gl.useProgram(prog);
|
||||||
|
|
||||||
|
const buf = gl.createBuffer()!;
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||||
|
gl.bufferData(
|
||||||
|
gl.ARRAY_BUFFER,
|
||||||
|
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||||
|
gl.STATIC_DRAW,
|
||||||
|
);
|
||||||
|
const aPos = gl.getAttribLocation(prog, "aPos");
|
||||||
|
gl.enableVertexAttribArray(aPos);
|
||||||
|
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const tex = gl.createTexture()!;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gl,
|
||||||
|
tex,
|
||||||
|
uHueShift: gl.getUniformLocation(prog, "uHueShift")!,
|
||||||
|
uSaturation: gl.getUniformLocation(prog, "uSaturation")!,
|
||||||
|
uLightness: gl.getUniformLocation(prog, "uLightness")!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
label, value, min, max, unit, onChange, gradient,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
unit: string;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
gradient?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">{label}</p>
|
||||||
|
<span className="text-xs font-mono text-text-tertiary tabular-nums w-16 text-right">
|
||||||
|
{`${value > 0 ? "+" : ""}${value}${unit}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-5 flex items-center">
|
||||||
|
{gradient && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 h-2 rounded-full pointer-events-none"
|
||||||
|
style={{ background: gradient }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 w-full appearance-none bg-transparent cursor-pointer",
|
||||||
|
"[&::-webkit-slider-thumb]:appearance-none",
|
||||||
|
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
|
||||||
|
"[&::-webkit-slider-thumb]:-mt-1",
|
||||||
|
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white",
|
||||||
|
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-border",
|
||||||
|
"[&::-webkit-slider-thumb]:shadow-sm",
|
||||||
|
gradient
|
||||||
|
? "[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent"
|
||||||
|
: "accent-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HueShifter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function HueShifter() {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imageReady, setImageReady] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const [hueShift, setHueShift] = useState(0);
|
||||||
|
const [saturation, setSaturation] = useState(0);
|
||||||
|
const [lightness, setLightness] = useState(0);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// glCanvasRef — WebGL result canvas (also the preview)
|
||||||
|
const glCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const glRef = useRef<GlState | null>(null);
|
||||||
|
|
||||||
|
// ── Load image → upload texture once ──────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc) return;
|
||||||
|
setImageReady(false);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const glCanvas = glCanvasRef.current;
|
||||||
|
if (!glCanvas) return;
|
||||||
|
glCanvas.width = img.naturalWidth;
|
||||||
|
glCanvas.height = img.naturalHeight;
|
||||||
|
|
||||||
|
if (!glRef.current) {
|
||||||
|
glRef.current = initGl(glCanvas);
|
||||||
|
if (!glRef.current) {
|
||||||
|
console.error("HueShifter: WebGL not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gl, tex } = glRef.current;
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||||
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||||
|
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||||
|
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
|
||||||
|
|
||||||
|
setImageReady(true);
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
// ── Re-render on GPU whenever sliders change ───────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const state = glRef.current;
|
||||||
|
if (!state || !imageReady) return;
|
||||||
|
|
||||||
|
const { gl, uHueShift, uSaturation, uLightness } = state;
|
||||||
|
gl.uniform1f(uHueShift, hueShift / 360);
|
||||||
|
gl.uniform1f(uSaturation, saturation / 100);
|
||||||
|
gl.uniform1f(uLightness, lightness / 100);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
}, [imageReady, hueShift, saturation, lightness]);
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
const loadFile = useCallback((file: File) => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(URL.createObjectURL(file));
|
||||||
|
setImageFile(file);
|
||||||
|
setHueShift(0);
|
||||||
|
setSaturation(0);
|
||||||
|
setLightness(0);
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImageReady(false);
|
||||||
|
glRef.current = null;
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}, [imageSrc]);
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file?.type.startsWith("image/")) loadFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const glCanvas = glCanvasRef.current;
|
||||||
|
if (!glCanvas || !imageFile || !imageReady) return;
|
||||||
|
glCanvas.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_recolored.png";
|
||||||
|
a.href = url;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setHueShift(0);
|
||||||
|
setSaturation(0);
|
||||||
|
setLightness(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDefault = hueShift === 0 && saturation === 0 && lightness === 0;
|
||||||
|
|
||||||
|
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||||
|
if (!imageSrc) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-1">Hue Shifter</h2>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Upload an image and shift its hue, saturation, and lightness to create colour
|
||||||
|
variants. Fully transparent pixels are preserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||||
|
dragOver
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 transition-colors",
|
||||||
|
dragOver ? "text-primary" : "text-text-tertiary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">
|
||||||
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-8">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex-1">Hue Shifter</h2>
|
||||||
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||||
|
{imageFile?.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isDefault}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary transition-colors",
|
||||||
|
isDefault
|
||||||
|
? "opacity-40 cursor-not-allowed"
|
||||||
|
: "hover:text-foreground hover:border-primary/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||||
|
"hover:text-destructive hover:border-destructive transition-colors",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!imageReady || isDefault}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||||
|
imageReady && !isDefault
|
||||||
|
? "bg-primary text-white hover:bg-primary/90"
|
||||||
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-5 space-y-5">
|
||||||
|
<Slider
|
||||||
|
label="Hue Shift"
|
||||||
|
value={hueShift}
|
||||||
|
min={-180}
|
||||||
|
max={180}
|
||||||
|
unit="°"
|
||||||
|
onChange={setHueShift}
|
||||||
|
gradient="linear-gradient(to right, hsl(0,80%,55%), hsl(60,80%,55%), hsl(120,80%,55%), hsl(180,80%,55%), hsl(240,80%,55%), hsl(300,80%,55%), hsl(360,80%,55%))"
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Saturation"
|
||||||
|
value={saturation}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
unit="%"
|
||||||
|
onChange={setSaturation}
|
||||||
|
gradient="linear-gradient(to right, hsl(210,0%,50%), hsl(210,0%,55%) 50%, hsl(210,90%,55%))"
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Lightness"
|
||||||
|
value={lightness}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
unit="%"
|
||||||
|
onChange={setLightness}
|
||||||
|
gradient="linear-gradient(to right, hsl(0,0%,10%), hsl(0,0%,50%) 50%, hsl(0,0%,92%))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side-by-side */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Original
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
<div style={CHECKERBOARD}>
|
||||||
|
<img src={imageSrc} alt="Original" className="w-full block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||||
|
Result
|
||||||
|
</p>
|
||||||
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
{/* WebGL canvas always in DOM; hidden until image is ready */}
|
||||||
|
<div style={CHECKERBOARD}>
|
||||||
|
<canvas
|
||||||
|
ref={glCanvasRef}
|
||||||
|
className={cn("w-full block", !imageReady && "hidden")}
|
||||||
|
/>
|
||||||
|
{!imageReady && (
|
||||||
|
<div className="aspect-square flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-10 h-10 opacity-20 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1588
panel/src/pages/ItemStudio.tsx
Normal file
1588
panel/src/pages/ItemStudio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,18 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Scissors,
|
||||||
|
Crop,
|
||||||
|
Palette,
|
||||||
|
Maximize2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useItems, type Item } from "../lib/useItems";
|
import { useItems, type Item } from "../lib/useItems";
|
||||||
|
import { ItemStudio } from "./ItemStudio";
|
||||||
|
import { BackgroundRemoval } from "./BackgroundRemoval";
|
||||||
|
import { CropTool } from "./CropTool";
|
||||||
|
import { HueShifter } from "./HueShifter";
|
||||||
|
import { CanvasTool } from "./CanvasTool";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -26,7 +35,7 @@ const RARITY_COLORS: Record<string, string> = {
|
|||||||
SSR: "bg-amber-500/20 text-amber-400",
|
SSR: "bg-amber-500/20 text-amber-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Tab = "all" | "studio";
|
type Tab = "all" | "studio" | "bgremoval" | "crop" | "hue" | "canvas";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SearchFilterBar
|
// SearchFilterBar
|
||||||
@@ -383,6 +392,7 @@ export default function Items() {
|
|||||||
setPage,
|
setPage,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
refetch,
|
||||||
} = useItems();
|
} = useItems();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("all");
|
const [activeTab, setActiveTab] = useState<Tab>("all");
|
||||||
@@ -436,6 +446,54 @@ export default function Items() {
|
|||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Item Studio
|
Item Studio
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("bgremoval")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "bgremoval"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scissors className="w-4 h-4" />
|
||||||
|
Background Removal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("crop")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "crop"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Crop className="w-4 h-4" />
|
||||||
|
Crop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("hue")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "hue"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Palette className="w-4 h-4" />
|
||||||
|
Hue Shifter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("canvas")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === "canvas"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
Canvas
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -475,17 +533,21 @@ export default function Items() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : activeTab === "studio" ? (
|
||||||
|
<ItemStudio
|
||||||
|
onSuccess={() => {
|
||||||
|
refetch();
|
||||||
|
setActiveTab("all");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : activeTab === "bgremoval" ? (
|
||||||
|
<BackgroundRemoval />
|
||||||
|
) : activeTab === "crop" ? (
|
||||||
|
<CropTool />
|
||||||
|
) : activeTab === "hue" ? (
|
||||||
|
<HueShifter />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
<CanvasTool />
|
||||||
<Sparkles className="w-16 h-16 text-text-tertiary mb-4" />
|
|
||||||
<h2 className="text-lg font-semibold text-text-secondary mb-2">
|
|
||||||
Item Studio
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-text-tertiary max-w-md">
|
|
||||||
AI-assisted item editor coming soon. Create and customize items with
|
|
||||||
generated icons, balanced stats, and lore descriptions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user