Compare commits

..

No commits in common. "main" and "feat/youtube-importer" have entirely different histories.

886 changed files with 229555 additions and 33056 deletions

View file

@ -22,51 +22,5 @@ SESSION_SECRET=changeme
# MAM API Configuration
MAM_API_URL=http://mam-api:3000
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
#
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
# user with the access they need — an 'admin' user (full access), or a user with
# the right project grants. A non-admin service token with no grants will get
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
# user is admin, so this only matters once auth is on.
AUTH_ENABLED=true
# CORS allowlist — comma-separated origins that may carry credentials to the API.
# Same-origin requests via the nginx reverse proxy do not need to be listed here.
# Leave empty to allow any origin (DEV ONLY).
ALLOWED_ORIGINS=
# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS,
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
TRUST_PROXY=false
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
# disable; the "Sign in with Google" button and the /auth/google routes only
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Must exactly match a redirect URI on the OAuth client, e.g.
# https://dragonflight.live/api/v1/auth/google/callback
OAUTH_REDIRECT_URL=
# Restrict sign-in to one Google Workspace domain (recommended). First login from
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
# by Google's stable subject id, never by email — so a Google login can never
# seize a pre-existing local account). An admin then grants project access.
# Leave blank to allow any verified Google account to self-provision (NOT advised).
GOOGLE_ALLOWED_DOMAIN=
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires
# the authenticator code (Google is treated as the first factor). Accounts without
# TOTP complete sign-in in one Google step.
# Playout / Master Control (MCR)
# Image tag the mam-api spawns when a channel starts. Build with:
# docker compose --profile build-only build playout
PLAYOUT_IMAGE=wild-dragon-playout:latest
# Base AMCP port — each channel binds to BASE + channel_id (in CasparCG terms).
PLAYOUT_AMCP_BASE_PORT=5250
# Auth (set to 'true' to require login; false for open/dev mode)
AUTH_ENABLED=false

3
.gitignore vendored
View file

@ -14,9 +14,6 @@ yarn-error.log*
# Build output
dist/
build/
# ...but the Premiere panel's packaging pipeline lives at build/ — keep it tracked.
!services/premiere-plugin/build/
!services/premiere-plugin/build/**
# OS
.DS_Store

153
DESIGN.md
View file

@ -1,153 +0,0 @@
# Design system
Documented from the live tokens in `services/web-ui/public/styles.css` and the patterns used across `screens-*.jsx`. Treat this as the source of truth for shared primitives; pages may override locally but should not invent new color or type scales.
## Color
Dark theme only. Tokens live in `:root` in `styles.css`. Tinted neutrals — no `#000`, no `#fff`.
### Surfaces
| Token | Hex | Use |
|---|---|---|
| `--bg-0` | `#0B0D11` | Page background, deepest surface |
| `--bg-1` | `#14171E` | Panels, sidebars, primary chrome |
| `--bg-2` | `#1B1F27` | Panel headers, hover state |
| `--bg-3` | `#232833` | Inputs, raised buttons, badges |
| `--bg-4` | `#2D3340` | Strongly raised elements (rare) |
### Borders + overlays
| Token | Use |
|---|---|
| `--border` (rgba white 6%) | Default 1px separator |
| `--border-strong` (rgba white 10%) | Emphasized separator |
| `--border-stronger` (rgba white 14%) | Hover/active border |
| `--hover` (rgba white 4%) | Subtle hover fill |
| `--hover-strong` (rgba white 7%) | Stronger hover fill |
### Text
| Token | Hex | Use |
|---|---|---|
| `--text-1` | `#F2F3F6` | Primary content |
| `--text-2` | `#A8AEBC` | Secondary content |
| `--text-3` | `#6B7280` | Labels, metadata |
| `--text-4` | `#4B5260` | Section labels, off-month, disabled |
### Accent
`--accent: #5B7CFA` (Frame.io-ish blue). Soft variants `--accent-soft` (14%) and `--accent-soft-2` (22%) for fills. `--accent-text: #B4C3FF` for high-contrast accent text.
**Restrained strategy.** Accent is the only saturated color in chrome. Status colors below appear only where they reflect actual state. Project colors appear only on project chips.
### Status
| Token | Hex | Use |
|---|---|---|
| `--success` | `#2DD4A8` | Done, healthy, ready |
| `--warning` | `#F5A623` | Processing, attention-needed |
| `--danger` | `#FF5B5B` | Failed, error |
| `--live` | `#FF3B30` | Currently recording (broadcast red, intentionally hotter than `--danger`) |
| `--purple` | `#B57CFA` | Editor / dev tags |
Each has a `*-soft` variant at ~14% for background fills.
### Project palette
Six fixed colors cycled by index in `data.jsx` (`PROJECT_COLORS`):
`#5B7CFA · #2DD4A8 · #FF5B5B · #F5A623 · #B57CFA · #6B7280`
Used only on the project chip / rail dot. Do not reuse for status meaning.
## Typography
- **Sans:** Geist (with `cv11`, `ss01` features enabled). All UI text by default.
- **Mono:** Geist Mono. URLs, IDs, timestamps, durations, technical metadata.
Body scale runs small for density:
| Size | Use |
|---|---|
| 10.5px, 600, uppercase, 0.060.08em letterspacing | Column heads, section labels |
| 1111.5px | Metadata, secondary rows |
| 1212.5px | Body in lists/tables |
| 13px, 500600 | Row labels, button text |
| 14px | Default body |
| 15px, 600 | Modal titles, panel heads |
| 2228px, 600+ | Page H1 (`.page-header h1`) |
Never use `gradient text` (impeccable absolute ban). Emphasis via weight and size only.
## Layout
- Sidebar: 232px fixed (`--sidebar-w`).
- Topbar: 56px (`--topbar-h`).
- Row height: 44px default, 36px compact (`--row-h`, `data-density="compact"`).
- Gap unit: 16px default, 12px compact (`--gap`).
- Border radius scale: 4 / 6 / 8 / 12 / 16 px (`--r-xs` → `--r-xl`).
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
- Do NOT nest panels.
### Page header
Standard screens use `.page > .page-header > h1`. Three screens are documented exceptions because they need full-bleed layouts and their own top-chrome:
- **Home** uses `.launcher` (lobby pattern: hero logo + tile grid + status pip).
- **Library** uses `.library-layout` (dual-pane rail + main). The h1 sits inside `.library-toolbar` as `.toolbar-title`.
- **Editor** uses `.editor-shell` (NLE with timeline + monitors). The beta banner doubles as its top chrome.
All other screens should render `<div className="page"><div className="page-header"><h1>…</h1>…</div>…</div>` for consistent IA and screen-reader hierarchy.
## Shadow
Two tokens, used sparingly:
- `--shadow-card`: subtle inset highlight + soft outer. Default for raised inputs.
- `--shadow-pop`: modal / popover / context menu.
No drop shadows on flat surfaces. No glow effects (except: the EPG now-line uses a tight `box-shadow` for the broadcast-red glow, and live status dots have a pulse halo; these are state cues, not decoration).
## Motion
- Default transition: 80120ms on background/border (`transition: background 80ms, border 80ms`).
- Heavier reveals: 200ms.
- Easing: prefer ease-out (no bounce, no elastic).
- Don't animate layout (width/height/top); animate transforms and opacity.
## Patterns
### Status badges (`.badge`)
Variants: `live` (red, animated dot), `success`, `danger`, `warning`, `accent`, `neutral`, `outline`. Tiny — 910px font, ~2px vertical padding. Reserved for state, not labels.
### Row tables (`.user-row`, `.token-row`, `.job-row`, `.schedule-row`, etc.)
CSS-grid with explicit columns. Header row uses `.head` (uppercase 10.5px). No card stacking — these are dense data lists.
### Schedule EPG (`.epg-*`)
Broadcast timeline pattern. Recorder rows × time-of-day axis. Single scrolling container with sticky left gutter (220px) and sticky top ruler (32px). Hour rhythm via `repeating-linear-gradient`. Now-line is a 1px hot-red vertical bar with `--live` glow, animated by re-rendering the line component every second (transform-only positioning, no layout thrash). Event blocks are absolute-positioned within each row, colored via `--epg-block-color` set per recorder's project color. Live events get a red gradient + pulse; failures get a glyph + full red border; past events fade to 0.55 opacity.
### Inputs
`.field-input``--bg-3` fill, 1px `--border`, `--r-sm`, 12.5px font. Focus: `--border-strong`.
### Status dot (`.signal-dot`, `.rec-dot`, etc.)
Small (~68px) circle, used inline with text. Recording dots pulse with a keyframe animation.
## Impeccable absolute bans (apply project-wide)
- No `border-left` / `border-right` greater than 1px as a colored accent (rewrite with full borders, leading icons, or background tint).
- No `background-clip: text` gradient text.
- No glassmorphism (blur + translucent) decoratively.
- No hero-metric template (big number, small label, gradient accent, supporting stats).
- No identical card grids.
- Modal as last resort — exhaust inline alternatives first.
- No em dashes in code or copy. Use commas, colons, parentheses, periods.
## To extend
When a new design need arises, prefer adding a variant to an existing primitive over inventing a new token. New tokens land in `styles.css`. New components land in the relevant `screens-*.jsx` only if reused; otherwise keep them local.

View file

@ -1,61 +0,0 @@
# Product
## What it is
Dragonflight (codebase name; UI brand: "Dragonflight · Wild Dragon Broadcast") is an on-prem broadcast media asset manager and live ingest controller. Operators capture from SRT, RTMP, and SDI sources, schedule windowed recordings against named recorders, transcode/proxy in the background, browse and clip in a library, import from YouTube, and hand off to Premiere or an in-house editor.
The deployable surface is a React (Babel-in-browser) single-page app served by nginx, talking to a Node/Express API backed by Postgres + Redis + BullMQ, with capture and worker containers handling the actual media.
Stack lives at `services/web-ui` (UI), `services/mam-api` (API), `services/capture`, `services/worker`, `services/editor`.
## Register
**Product.** This is operator UI for a working broadcast tool, not a brand site or marketing surface. Design serves the operator's job, not the brand's identity.
## Users
Primary: broadcast operators and engineers running live productions. They schedule and supervise back-to-back recordings across multiple recorders in a single shift. They care about: what's recording right now, what's about to start, what failed, and which recorder is bound to what source.
Secondary: editors and producers who consume the resulting library, comment on assets, request proxy regeneration. They mostly live in the Library and Asset detail screens, not the scheduler.
Tertiary: admins managing recorders, users, cluster nodes, and storage. Live in the Admin screens.
## Product purpose
Replace a stack of one-trick tools (NewBlue scheduler, vMix capture, ad-hoc Premiere ingest, manual S3 syncs) with a single operator surface that supervises recorders, owns the asset catalog through proxy generation, and stays out of the editor's way once footage lands.
## Tone
Function-first. Dense. Operations-room. Mono fonts where data lives. Small type. Restrained chrome. The operator should be able to glance at any screen and read the state of the system in under a second; they should never wonder "is this still happening" or "did that finish."
Not: marketing-warm, conversational, gamified, congratulatory.
## Strategic principles
1. **Glance-readable status.** Every list, every cell, every badge must answer "what is the state of this thing right now" without a hover.
2. **Trust the operator.** No confirmation modals for reversible actions. No nag, no toasts for routine success. Errors stay visible until acknowledged.
3. **Time is the spine.** This product is about time-based events (recordings, schedules, jobs). UIs should privilege time as a primary axis, not bury it under categorical filters.
4. **Density over whitespace.** Operators run multi-monitor setups and want maximum signal per pixel. Generous whitespace is a brand-site reflex; reject it here.
5. **No half-states.** Pending UIs disable controls; live UIs show live data; failed UIs show the failure inline, not in a separate notification feed.
## Anti-references
Steer away from:
- **Linear-pastel SaaS aesthetics.** Purples, mint accents, friendly empty states with cartoon line illustrations.
- **Google-Calendar generic.** A neutral month grid with rounded event chips, no operational signal, optimized for "is Friday free" rather than "is recorder A in conflict at 14:00."
- **Gantt-chart project-management feel.** Implies long-horizon planning of tasks with dependencies; this product is hour-scale broadcast operations.
- **Cards-for-everything.** Identical card grids of icon+label+value. Particularly the SaaS hero-metric template.
- **Decorative blur / glassmorphism / gradient text.** Read as decorative AI slop in a broadcast-ops context.
- **NewBlue / Wirecast skinning.** Heavy bevels, gradient buttons, drop shadows. Read as outdated broadcast software.
## Decision context (Schedule v2)
The Schedule screen was rebuilt as an EPG (electronic program guide) timeline. Operator confirmed:
- Density: **heavy / back-to-back, many recorders all day** — month grid was the wrong primary.
- At-a-glance signals: now/next, recorder bookings (conflicts), project context, failure history.
- Aesthetic: **studio / cinematic — dark, type-led, accent moments.** DaVinci-Resolve-panel territory.
- Scope: full rethink — replace the primary view.
Implementation: recorder rows × time-of-day horizontal axis, sticky gutter + ruler, vertical hot-red now-line, event blocks colored by project, status pills in a top strip. Today / Week / List views.

250
README.md
View file

@ -1,128 +1,49 @@
# Dragonflight
Self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. Handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
Self-hosted broadcast media-asset management. Replaces Grass Valley AMPP
FramelightX. SDI / SRT / RTMP ingest, growing-file editing via Premiere
Pro, S3-compatible storage, scheduling, and a queue-driven proxy pipeline.
> Repo renamed from `wild-dragon``dragonflight` (2026-05-23). The old URL still redirects.
> Repo renamed from `wild-dragon``dragonflight` (2026-05-23). The old
> URL still redirects.
## Home Dashboard
## Features
<img src="docs/screenshots/01-home.png" alt="Home Dashboard" width="800" />
- **Ingest** — SRT (caller + listener), RTMP, and SDI capture via
Blackmagic DeckLink cards (FFmpeg patched against SDK 16.x); per-recorder
codec settings (ProRes / H.264 / DNxHR / HEVC) and audio routing
- **Growing-file editing** — capture writes the hi-res master to a local
SMB landing zone; editors can mount the share in Premiere Pro and edit
the live file via the included CEP panel, then relink to the final S3
master after promotion
- **Recorder scheduler** — one-shot, daily, or weekly windows; a 15s tick
loop fires the existing /recorders/:id/start + /stop endpoints
- **Library** — projects, bins, asset detail with frame-anchored
persistent comments, right-click context menu (move-to-bin, rename,
delete), and a global cmd/ctrl-K search across assets / projects /
recorders / jobs / users
- **Jobs** — BullMQ-backed proxy + thumbnail queue with per-job retry,
bulk "retry all failed", and inline error messages
- **Settings** — S3 (with env-var fallback), global proxy encoder
(CPU/libx264 or GPU/NVENC/VAAPI), growing-files config, capture SDK
uploader (Blackmagic / AJA / Deltacast)
- **Cluster** — primary + worker topology with heartbeat health, remote
node-agent for off-host DeckLink capture
- **API**`deploy/api-smoke.sh` exercises every endpoint (27 routes,
pass/fail summary)
The home screen provides quick access to all major features and displays system status at a glance:
- **Library** — Browse projects, bins, and assets with hover-scrub previews
- **Recorders** — View configured capture devices and their status
- **Editor** — Timeline editor with cross-clip preview and render queue
- **Jobs** — Proxy and thumbnail queue with retry controls
- **Settings** — Configure storage, encoder, growing files, and capture SDK
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
## Services
---
| Service | Port | Description |
|---------|------|-------------|
| **web-ui** | 47434 | Browser SPA + capture controls |
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler tick |
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP capture sidecar |
| **worker** | — | BullMQ proxy + thumbnail workers |
| **db** | 5432 | PostgreSQL 16 |
| **queue** | 6379 | Redis 7 |
## Core Features
### 1. Live Ingest & Capture
**Multi-protocol source capture with per-recorder codec settings**
Dragonflight ingests from multiple sources simultaneously:
- **SRT** (Secure Reliable Transport) — caller and listener modes
- **RTMP** — standard streaming protocol
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
Each recorder can be configured with independent codec settings:
- ProRes (hi-res masters)
- H.264 / H.265 (proxies)
- DNxHR (Avid compatibility)
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
### 2. Growing-File Editing
**Live editing in Premiere Pro while capture is still writing**
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
- Real-time clip detection and frame-accurate trimming
- One-click relink to final S3 master after promotion
- No waiting for capture to finish before editorial begins
### 3. Recorder Scheduler
**Time-windowed recording automation**
Schedule recordings with:
- One-shot, daily, or weekly recurrence
- Automatic start/stop via 15-second tick loop
- Conflict detection across recorders
- Project and bin assignment at schedule time
### 4. Library & Asset Management
**Browse, search, and organize captured footage**
The Library screen provides:
- Project and bin hierarchy
- Asset detail view with frame-anchored persistent comments
- Right-click context menu (move-to-bin, rename, delete)
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
- Hover-scrub preview with HLS playback
### 5. Jobs Queue
**BullMQ-backed proxy and thumbnail generation**
Automated background processing:
- Per-job retry logic with exponential backoff
- Bulk "retry all failed" for batch recovery
- Inline error messages with actionable diagnostics
- Status tracking: ingesting → processing → ready
Proxy encoder options:
- CPU-based: libx264 (H.264)
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
### 6. Timeline Conform & Export
**FCP XML export with server-side FFmpeg rendering**
The Premiere Pro panel exports FCP XML with:
- Server-side conform via FFmpeg
- Multiple output formats: H.264, H.265, ProRes
- Resolution presets: Broadcast, Web, Archive
- Batch processing with job queue integration
### 7. Hi-Res Auto-Relink
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
After editing on proxies:
- Select clips in Premiere
- Trigger relink from the CEP panel
- Server trims hi-res segments to exact in/out points
- Concurrent trim worker pool for speed
- 24-hour TTL with automatic cleanup
### 8. Settings & Configuration
**Centralized control for storage, encoding, and capture**
Configure:
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
- **Growing Files** — SMB path, retention, auto-promotion
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
### 9. Cluster & Distributed Capture
**Primary + worker topology with remote DeckLink nodes**
- Primary node runs API, scheduler, and web UI
- Worker nodes handle proxy/thumbnail jobs
- Remote capture nodes run DeckLink cards off-host
- Heartbeat health monitoring
- Automatic failover and recovery
### 10. Admin & User Management
**Role-based access, token auth, and cluster monitoring**
- User creation and role assignment
- API token generation for integrations
- Container and cluster node status
- System health dashboard
---
## Quick Start
## Quick start
```bash
# Clone (repo renamed; old URL still redirects)
@ -160,98 +81,23 @@ assets POST ──► proxy job ──► worker
└─ status: ingesting → processing → ready
```
## Tech Stack
## Tech stack
- **Runtime:** Node.js 22, Docker Compose
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
## Services
| Service | Port | Purpose |
|---------|------|---------|
| **web-ui** | 47434 | Browser SPA + capture controls |
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
| **worker** | — | BullMQ proxy + thumbnail workers |
| **db** | 5432 | PostgreSQL 16 |
| **queue** | 6379 | Redis 7 |
---
## Workflow Example: Live-to-Edit
1. **Operator** schedules a recording on Recorder A for 14:0015:30, assigns to "News/Segment-A" project
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
4. **Editor** trims and marks in/out points while capture is still writing
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
6. **Editor** clicks "Relink to Master" in CEP panel
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
8. **Premiere** relinks proxy clips to trimmed master
9. **Editor** exports final timeline via FCP XML conform
Total time from end of capture to relinked master: ~2 minutes.
---
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches; ProRes, H.264, HEVC,
DNxHR, MOV/MP4/MXF containers
- **Storage:** S3-compatible (RustFS) for masters + proxies + thumbnails
## Operations
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
- `deploy/onboard-node.sh` — provision a remote worker host
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
## Authentication
Dragonflight uses local username/password authentication with two transports:
- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout.
- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`.
### First-run setup
On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser.
With no users in the database, the login screen renders a "First-run setup" form
instead — fill it in to create the first admin and you are logged in immediately.
Subsequent users are created from `Settings → Users` (any signed-in user can
create others — flat access).
### Dev mode
Setting `AUTH_ENABLED=false` disables all auth checks; a synthetic `dev` user
is attached to every request. **Never deploy this way.** The dev user row is
seeded with a hash that no real password can match, so flipping
`AUTH_ENABLED=true` later does not expose the dev account.
### Recovering a forgotten admin password
Any signed-in user can reset another user's password from `Settings → Users`.
If no one can sign in (all admins forgot their passwords), reset directly in
Postgres:
```sql
-- generate a fresh bcrypt hash with:
-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here'
UPDATE users SET password_hash = '<bcrypt-hash>', password_updated_at = NOW()
WHERE username = 'admin';
```
### `AUTH_ENABLED` transition
When flipping `AUTH_ENABLED=false``true` on an existing install:
1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out).
2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI.
3. Set `TRUST_PROXY=true` when behind nginx (required for rate-limit accuracy).
4. Restart `mam-api`.
5. Visit the UI — first-run setup will appear if no real users exist yet.
---
- `deploy/api-smoke.sh` — verify every API endpoint after a deploy
- `deploy/onboard-node.sh` — provision a remote worker host (DeckLink
cards on a separate machine)
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install +
growing-file capture flow
## License

View file

@ -1,101 +0,0 @@
# Playout / Master Control — Implementation Work Log
**Branch:** `feat/playout-mcr` (off `main`)
**Started:** 2026-05-30
**Status:** Code complete, awaiting runtime validation
Tracks the build of the playout (MCR) subsystem against the design at
`docs/superpowers/specs/2026-05-30-playout-mcr-design.md`.
---
## Commit sequence
| # | Commit | Scope |
|---|--------|-------|
| 1 | `docs(playout)` | Design spec, §7 questions answered |
| 2 | `feat(mam-api): migration 029` | Six tables, failover columns, audio_normalized flag |
| 3 | `feat(worker): playout-stage` | S3 → /media + EBU R128 loudnorm + index.js wiring |
| 4 | `feat(playout): sidecar` | CasparCG image + AMCP shim, HLS preview consumer, fps-aware frame math |
| 5 | `feat(mam-api): /playout control plane + auto-failover` | Routes + scheduler health tick + restartChannel helper |
| 6 | `feat(web-ui): MCR page` | screens-playout, styles, app/shell/index.html wiring |
| 7 | `build(playout): compose wiring + .env knobs` | /media volume, queue addition, build-only service |
| 8 | `docs(playout): work log` | This file |
## Resolved §7 decisions (2026-05-30)
- **Audio loudness:** pre-normalize at stage time. ffmpeg `loudnorm` two-pass
(I=-23 LUFS, TP=-1 dBTP, LRA=11), linear mode preserves dynamics. Output
AAC 192k @ 48 kHz, video stream copied. Per-item `audio_normalized` flag
so re-stages of the same asset skip the pass.
- **Frame rate:** `1080p5994` default (was `1080i5994`). Per-channel
override allowed via `video_format`. `fpsFor(videoFormat)` helper in
the sidecar drives SEEK / LENGTH / transition-frames math.
- **Preview latency:** HLS v1. CasparCG runs a second FFMPEG consumer
alongside the primary output, writing `/media/live/<channel_id>/index.m3u8`
(~600 kbps, 2s segments, 6-window list). Web UI plays via the existing
HLS plumbing.
- **Failover:** auto-restart on healthy node for NDI/SRT/RTMP. Alert-only
for DeckLink (device-index pinning makes blind re-placement risky).
Scheduler tick (PG advisory lock, same lock as recorder schedules) polls
sidecar `/status`; ~3 missed checks → `restartChannel(id)` picks the most
recently-seen-online other node, bumps `restart_count`, calls `/start`.
## Architecture notes
**Sidecar model.** One CasparCG container per channel. Spawned by mam-api
via local Docker socket (primary node) or remote node-agent
`/sidecar/start`. Tracked in `playout_sidecars` plus `playout_channels.container_id`.
Killed on `/stop` or by `restartChannel` during failover.
**Media flow.**
```
S3 master/proxy → playout-stage worker → /media/playout/<assetId>.<ext>
(loudnormed, AAC@-23 LUFS)
CasparCG channel #1
primary consumer HLS consumer
(DeckLink/NDI/ ↓
SRT/RTMP) /media/live/<ch_id>/*.m3u8
```
**Port contention.** `assertDeckLinkFree()` blocks starting a SDI channel
when a recorder or another channel on the same node+device_index is active.
**Failover scope.** NDI/SRT/RTMP have no hardware tie, so any healthy
cluster_node is eligible. DeckLink channels surface an alert in the UI
(`status='error'` + `error_message`) and require operator intervention.
## Testing checklist
- [ ] Apply migration 029 on dev DB
- [ ] Build playout image: `docker compose --profile build-only build playout`
- [ ] Build web-ui (`screens-playout` joins the esbuild list automatically)
- [ ] Create channel via POST /api/v1/playout/channels (SRT first, no HW)
- [ ] Stage 2-3 assets to a playlist, verify loudnorm metadata in stderr
- [ ] Start channel → sidecar container appears in `docker ps`
- [ ] AMCP smoke: `telnet <host> 5250`, `VERSION`, `INFO`
- [ ] Play playlist; verify HLS at /media/live/<id>/index.m3u8
- [ ] Skip / pause / resume / stop
- [ ] As-run log: GET /api/v1/playout/channels/:id/asrun
- [ ] Kill sidecar container → scheduler should restart on another node
within ~3 ticks (~45s), restart_count increments
- [ ] DeckLink channel kill: status flips to 'error', NO restart attempt
- [ ] Try starting a decklink channel on a device_index already held by a
recorder → 409
- [ ] MCR UI smoke: nav entry visible, page renders, drag-drop adds items,
transport buttons hit the API
## Known gaps (deferred)
- No WebRTC preview (HLS-only v1 — 4-6s lag, fine for confidence monitor).
- No graphics/CG overlay layer in Phase A (templates land in Phase B).
- No Phase B scheduler / 24/7 wall-clock channel (schema is in place,
scheduler tick is not).
- No multi-channel grid view (one channel at a time per page).
- No timecode / remaining-duration overlay (would need CasparCG INFO poll).
- No audio level meters on the UI.
- `restartChannel` updates DB state and triggers `/start`; if the new node
also fails repeatedly, there's no exponential backoff yet — bounded only
by the manual stop button.

View file

@ -16,9 +16,7 @@
# Optional env vars (needed only if starting the worker or capture profiles):
# REDIS_URL, DATABASE_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY
# BMD_DEVICE_0 DeckLink device path (default: /dev/blackmagic/dv0)
# (DeckLink IO / Quad cards expose /dev/blackmagic/io* instead — set BMD_DEVICE_PREFIX=io)
# BMD_DEVICE_1 DeckLink device path (default: /dev/blackmagic/dv1)
# BMD_DEVICE_PREFIX Naming prefix for synthesized BMD_COUNT-based devices (default: dv). Use 'io' for IO/Quad.
# LIVE_DIR Host path for HLS live segments (default: /mnt/NVME/MAM/wild-dragon-live)
#
# Profiles:
@ -50,15 +48,13 @@ services:
NODE_IP: ${NODE_IP:-}
AGENT_PORT: ${AGENT_PORT:-7436}
HEARTBEAT_MS: ${HEARTBEAT_MS:-30000}
GPU_COUNT: ${GPU_COUNT:--1}
BMD_COUNT: ${BMD_COUNT:--1}
BMD_MODEL: ${BMD_MODEL:-}
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
GPU_COUNT: ${GPU_COUNT:--1}
BMD_COUNT: ${BMD_COUNT:--1}
BMD_MODEL: ${BMD_MODEL:-}
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /dev:/dev:ro
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro
devices:
- /dev/blackmagic:/dev/blackmagic
@ -100,31 +96,6 @@ services:
networks:
- wild-dragon-worker
# worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to
# zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here.
worker-l4:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
image: wild-dragon-worker-gpu:latest
runtime: nvidia
restart: unless-stopped
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: proxy,conform,trim
PROXY_CONCURRENCY: "3"
NVIDIA_VISIBLE_DEVICES: GPU-13acf439-8bf4-a5e0-7804-c1071bca547a
WORKER_LABEL: "zampp2 / L4"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks:
- wild-dragon-worker
networks:
wild-dragon-worker:
driver: bridge

View file

@ -40,7 +40,6 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- /mnt/NVME/MAM/wild-dragon-live:/live
- /mnt/NVME/MAM/wild-dragon-growing:/growing
- /mnt/NVME/MAM/wild-dragon-media:/media
- /mnt/NVME/MAM/sdk:/sdk
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
@ -56,14 +55,9 @@ services:
S3_REGION: ${S3_REGION:-us-east-1}
SESSION_SECRET: ${SESSION_SECRET}
AUTH_ENABLED: ${AUTH_ENABLED:-false}
TRUST_PROXY: ${TRUST_PROXY:-false}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
DOCKER_NETWORK: wild-dragon_wild-dragon
NODE_IP: ${NODE_IP}
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
CAPTURE_TOKEN: ${CAPTURE_TOKEN}
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
deploy:
resources:
reservations:
@ -98,15 +92,8 @@ services:
networks:
- wild-dragon
# ── GPU worker pool (capability-routed) ──────────────────────────────
# worker-p4: HEAVY tier (proxy/conform/trim) on the Tesla P4 (NVENC).
# Also runs the promotion scanner (RUN_PROMOTION) — exactly one worker must.
worker-p4:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
image: wild-dragon-worker-gpu:latest
runtime: nvidia
worker:
build: ./services/worker
depends_on:
- queue
- db
@ -119,59 +106,8 @@ services:
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
GROWING_PATH: /growing
# Includes `import` (YouTube importer): the import queue had no consumer
# after the capability-routing split, so import jobs sat unprocessed and
# assets stayed `ingesting` forever. import is concurrency-1 + network-
# bound, so one consumer (this heavy/primary worker) is sufficient.
WORKER_QUEUES: proxy,conform,trim,import,playout-stage
RUN_PROMOTION: "true"
PROXY_CONCURRENCY: "2"
PLAYOUT_MEDIA_DIR: /media
NVIDIA_VISIBLE_DEVICES: GPU-79afca3e-2ab2-a6ea-1c44-706c1f0a26d6
WORKER_LABEL: "zampp1 / Tesla P4"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
volumes:
- /mnt/NVME/MAM/wild-dragon-growing:/growing
- /mnt/NVME/MAM/wild-dragon-media:/media
networks:
- wild-dragon
# worker-p400a/b: LIGHT tier (thumbnail/filmstrip) on the two Quadro P400s.
worker-p400a:
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: [queue, db, worker-p4]
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: thumbnail,filmstrip
NVIDIA_VISIBLE_DEVICES: GPU-331c53ea-2ed9-0007-e364-c1451775948f
WORKER_LABEL: "zampp1 / P400 #1"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks:
- wild-dragon
worker-p400b:
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: [queue, db, worker-p4]
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: thumbnail,filmstrip
NVIDIA_VISIBLE_DEVICES: GPU-b514a592-9077-44bd-d9e8-9efa0591ef88
WORKER_LABEL: "zampp1 / P400 #2"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks:
- wild-dragon
@ -181,21 +117,20 @@ services:
- "${PORT_WEB_UI:-7434}:80"
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /run/systemd:/run/systemd
networks:
- wild-dragon
# Build-only: the CasparCG sidecar image. mam-api spawns these on-demand per
# channel (one container per playout channel), so this service is never up'd —
# it exists so `docker compose build playout` produces the image the API tags
# via PLAYOUT_IMAGE. Profile excludes it from default `up`.
playout:
profiles: ["build-only"]
build: ./services/playout
image: wild-dragon-playout:latest
editor:
build: ./services/editor
depends_on:
- mam-api
ports:
- "${PORT_EDITOR:-7435}:80"
networks:
- wild-dragon
volumes:
postgres_data:

View file

@ -1,186 +0,0 @@
# Dragonflight · Feature Overview
Dragonflight is a self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. It handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
## Home Dashboard
![Home Dashboard](./screenshots/01-home.png)
The home screen provides quick access to all major features and displays system status at a glance:
- **Library** — Browse projects, bins, and assets with hover-scrub previews
- **Recorders** — View configured capture devices and their status
- **Editor** — Timeline editor with cross-clip preview and render queue
- **Jobs** — Proxy and thumbnail queue with retry controls
- **Settings** — Configure storage, encoder, growing files, and capture SDK
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
---
## Core Features
### 1. Live Ingest & Capture
**Multi-protocol source capture with per-recorder codec settings**
Dragonflight ingests from multiple sources simultaneously:
- **SRT** (Secure Reliable Transport) — caller and listener modes
- **RTMP** — standard streaming protocol
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
Each recorder can be configured with independent codec settings:
- ProRes (hi-res masters)
- H.264 / H.265 (proxies)
- DNxHR (Avid compatibility)
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
### 2. Growing-File Editing
**Live editing in Premiere Pro while capture is still writing**
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
- Real-time clip detection and frame-accurate trimming
- One-click relink to final S3 master after promotion
- No waiting for capture to finish before editorial begins
### 3. Recorder Scheduler
**Time-windowed recording automation**
Schedule recordings with:
- One-shot, daily, or weekly recurrence
- Automatic start/stop via 15-second tick loop
- Conflict detection across recorders
- Project and bin assignment at schedule time
### 4. Library & Asset Management
**Browse, search, and organize captured footage**
The Library screen provides:
- Project and bin hierarchy
- Asset detail view with frame-anchored persistent comments
- Right-click context menu (move-to-bin, rename, delete)
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
- Hover-scrub preview with HLS playback
### 5. Jobs Queue
**BullMQ-backed proxy and thumbnail generation**
Automated background processing:
- Per-job retry logic with exponential backoff
- Bulk "retry all failed" for batch recovery
- Inline error messages with actionable diagnostics
- Status tracking: ingesting → processing → ready
Proxy encoder options:
- CPU-based: libx264 (H.264)
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
### 6. Timeline Conform & Export
**FCP XML export with server-side FFmpeg rendering**
The Premiere Pro panel exports FCP XML with:
- Server-side conform via FFmpeg
- Multiple output formats: H.264, H.265, ProRes
- Resolution presets: Broadcast, Web, Archive
- Batch processing with job queue integration
### 7. Hi-Res Auto-Relink
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
After editing on proxies:
- Select clips in Premiere
- Trigger relink from the CEP panel
- Server trims hi-res segments to exact in/out points
- Concurrent trim worker pool for speed
- 24-hour TTL with automatic cleanup
### 8. Settings & Configuration
**Centralized control for storage, encoding, and capture**
Configure:
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
- **Growing Files** — SMB path, retention, auto-promotion
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
### 9. Cluster & Distributed Capture
**Primary + worker topology with remote DeckLink nodes**
- Primary node runs API, scheduler, and web UI
- Worker nodes handle proxy/thumbnail jobs
- Remote capture nodes run DeckLink cards off-host
- Heartbeat health monitoring
- Automatic failover and recovery
### 10. Admin & User Management
**Role-based access, token auth, and cluster monitoring**
- User creation and role assignment
- API token generation for integrations
- Container and cluster node status
- System health dashboard
---
## Architecture
```
SDI / SRT / RTMP ──► capture (FFmpeg)
├─ HLS preview tee ──► /live/<assetId>/index.m3u8
└─ master output
├─ growing_enabled=true:
│ /growing/<projectId>/<clip>.mov
│ (Premiere mounts SMB, edits live)
│ └─► promotion worker uploads to S3
└─ growing_enabled=false:
multipart stream → S3
assets POST ──► proxy job ──► worker
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
├─ thumbnail job
└─ status: ingesting → processing → ready
```
## Tech Stack
- **Runtime:** Node.js 22, Docker Compose
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
## Services
| Service | Port | Purpose |
|---------|------|---------|
| **web-ui** | 47434 | Browser SPA + capture controls |
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
| **worker** | — | BullMQ proxy + thumbnail workers |
| **db** | 5432 | PostgreSQL 16 |
| **queue** | 6379 | Redis 7 |
---
## Workflow Example: Live-to-Edit
1. **Operator** schedules a recording on Recorder A for 14:0015:30, assigns to "News/Segment-A" project
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
4. **Editor** trims and marks in/out points while capture is still writing
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
6. **Editor** clicks "Relink to Master" in CEP panel
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
8. **Premiere** relinks proxy clips to trimmed master
9. **Editor** exports final timeline via FCP XML conform
Total time from end of capture to relinked master: ~2 minutes.
---
## Operations
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
- `deploy/onboard-node.sh` — provision a remote worker host
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow

View file

@ -29,28 +29,15 @@ and the panel relinks Premiere to the hi-res original.
## Premiere panel install
Grab the latest release artifact and run it — the installer handles the file
copy, registry/plist debug-mode flip, and removes any legacy
`com.wilddragon.mam.panel` install:
- **Windows:** `dragonflight-premiere-panel-<version>-windows-setup.exe`
- **macOS / Win:** `dragonflight-premiere-panel-<version>.zxp` — install via
[Anastasiy's ZXP Installer](https://install.anastasiy.com/) (free GUI)
Releases live at
<https://forge.wilddragon.net/zgaetano/dragonflight/releases>.
Building locally (requires Windows for the `.exe`, any OS for the `.zxp`):
```
cd services/premiere-plugin/build
npm install
powershell -File build-all.ps1 # or: node build-zxp.mjs
# macOS
rsync -a services/premiere-plugin/ ~/Library/Application\ Support/Adobe/CEP/extensions/com.wilddragon.mam.panel/
# Windows
robocopy services\premiere-plugin %APPDATA%\Adobe\CEP\extensions\com.wilddragon.mam.panel /MIR
```
The Windows installer takes care of `PlayerDebugMode`. If you installed the
ZXP and the panel does not appear in **Window → Extensions**, enable debug
mode manually:
Enable CEP debug:
```
# macOS

View file

@ -1,116 +0,0 @@
# Work Log — May 2026
## Summary
Session focused on auth system architecture, dashboard redesign, and audio track inspector. Multiple iterations on auth approach; settled on simplified local-account model with RBAC. Dashboard rebuilt as control-room status board. Audio tab completed with full metering and fader controls.
## Auth System Work
### Commits
- `002e5ac` — auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
- `d1f9557` — auth: park login flow — circle back
- `9726dbb` — Revert "auth: top-to-bottom rework..."
- `4172b0d` — rip out entire auth/login flow
### What Happened
Attempted comprehensive auth rewrite including:
- Local user account system with bcrypt hashing
- Role-based access control (admin/editor/viewer)
- Client tagging for audit trails
- Environment-based bootstrap (AUTH_ENABLED flag)
- Session management with PostgreSQL backing
**Decision**: Reverted entire auth work. Reason: complexity vs. current product stage. System was over-engineered for self-hosted use case where auth is optional.
**Current State**: Auth disabled by default (AUTH_ENABLED=false). When enabled, system returns synthetic /auth/me endpoint. No persistent user management yet.
### Related Fixes
- `cfcbec0` — fix(auth): make AUTH_ENABLED=true workable end-to-end
- `e71c330` — fix(auth): remove manual session.save() — was suppressing Set-Cookie header
- `65684aa` — fix(auth): ensure sessions table exists + log session.save errors
## Dashboard Redesign
### Commits
- `a48e1d9` — dashboard: rebuild as control-room status board (on air / up next / attention / work)
- `e5e0656` — dashboard: redesign stat cards, compress header, improve density
- `5de1e3d` — dashboard: add dense stat cards, cluster bars, job rows, sparkline fixes
- `48d54a3` — dashboard: add missing dash-* CSS classes; cluster: add stat-row/stat-card CSS
### What Changed
Transformed dashboard from generic metrics view to broadcast control-room interface:
- **On Air** section: live stream status, bitrate, duration
- **Up Next** section: queued clips/segments
- **Attention** section: warnings, errors, resource alerts
- **Work** section: active jobs, encoding progress
Added visual components:
- Dense stat cards with icon + value + trend
- Cluster health bars (CPU, memory, disk per node)
- Job progress rows with ETA
- Sparkline charts for trend visualization
CSS infrastructure added for consistent spacing/sizing across dashboard components.
## Audio Tab Implementation
### Commit
- `c48c7e6` — feat(audio-tab): full audio track inspector with meters, mute/solo, faders
### Features
- Per-track audio meters (VU-style, real-time)
- Mute/solo buttons per track
- Fader controls (0-100 dB range)
- Master output meter
- Track naming/labeling
- Visual feedback for clipping/peaks
## Other Work
### Storage & Admin
- `64d739b` — feat(admin): unified Storage settings page with mount/bucket health diagnostics
- `a44d8bd` — feat(admin): live video-presence indicators on cluster DeckLink ports
### Player & Streaming
- `a86c1c7` — fix(player): stitch S3 ranges around RustFS empty-body bug (#143)
- `d257a19` — fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
- `37247fd` — fix(video): direct S3 signed URL for streaming + proxy bitrate 1.5Mbps
- `e4d4c00` — feat(proxy): VBR 500k-1M encoding for proxy generation
### Cluster & Hardware
- `55ff2e7` — feat(cluster): full hardware breakdown per node
- `5ff507b` — fix(node-agent): use nsenter to run nvidia-smi in host mount namespace
- `558c18e` — fix(node-agent): detect GPUs via docker run --gpus all ubuntu:22.04
- `a6f045b` — fix(node-agent): probe GPU via Docker API async at startup, cache result
### Release & Cleanup
- `04ce096` — chore: 1.2 ship-prep sweep — close 38 issues
- `f0f6156` — release: add v1.1.0 ZXP artifact (Growing tab + visual system alignment)
## Blockers / Open Questions
### Auth System
- **Decision needed**: Should auth be mandatory for production? Current design assumes optional.
- **API endpoints missing**: `/users`, `/auth/me`, `/groups` routes not yet implemented in mam-api
- **Frontend expects**: Users list, groups management, role-based UI filtering
### Dashboard
- Real data integration needed (currently mock data)
- Cluster stats endpoint integration
- Job queue polling/WebSocket updates
### Audio
- Backend audio processing pipeline not yet connected
- Metering data source undefined
- Fader changes need routing to encoder
## Next Steps (Recommended)
1. **Clarify auth requirements**: Is user management needed for v1.2? If yes, implement `/users` and `/groups` endpoints.
2. **Connect dashboard to live data**: Wire cluster stats, job queue, stream status to real endpoints.
3. **Audio backend integration**: Define audio processing pipeline and metering data flow.
4. **Testing**: Add integration tests for auth flow, dashboard data binding, audio control.
---
**Session ended**: 2026-05-27 06:31 CDT
**Status**: Work logged, auth decision documented, next steps identified

View file

@ -1,197 +0,0 @@
# All-Intra HEVC (NVENC) Growing-File Ingest
Date: 2026-05-29 | Status: design, pending validation gate (see §8)
Authors: Zac + Claude
## 1. Purpose
Replace the CPU-bound ProRes capture encode with **All-Intra HEVC on NVENC**
as the growing-file master codec, so we can:
- **Offload ingest encode from CPU to GPU** (the current scaling wall), and
- **Keep edit-while-record** (all-intra => growing file stays editable), and
- **Scale to up to 8 simultaneous signals per machine**, across Blackmagic
today and Deltacast + AJA later.
This doc captures the target design AND the current working system it builds on,
so it is self-contained for whoever implements it.
## 2. Why this codec
Growing-file editing (Premiere/Avid mounting a still-recording file over SMB)
requires two things: **intra-frame** (every frame a keyframe, so a partial file
is decodable to the last whole frame) and a **container whose index is not
deferred to EOF**. ProRes/DNxHR satisfy this but are CPU-only (NVIDIA has no
ProRes encoder). Long-GOP H.264/HEVC/AV1 do NOT work for edit-while-record.
**All-Intra HEVC (`-g 1 -bf 0`) via `hevc_nvenc`** is the one path that is both
GPU-accelerated AND all-intra: it breaks the "ProRes must be CPU" constraint
without losing edit-while-record. Trade-off: All-Intra bitrate approaches
ProRes, so the win is **CPU offload, not storage**. AV1 is rejected (no NLE
edit support; av1_nvenc absent from our ffmpeg builds).
## 3. Current working system (what we build on)
### Topology
- **zampp1** (172.18.91.200): primary. Runs db (postgres), queue (redis),
mam-api (:47432), web-ui (:47434), and the GPU worker pool. GPUs: Tesla P4 +
2x Quadro P400. Repo at /opt/wild-dragon (its own clone).
- **zampp2** (172.18.91.216): worker/capture node. 12-vCPU QEMU VM, NVIDIA L4,
4x Blackmagic DeckLink (exposed as /dev/blackmagic/io0..io3). Runs node-agent
(:7436). Repo at /opt/wild-dragon (separate clone).
- The repo is checked out independently on BOTH nodes; node-specific files
(node-agent, capture, worker overlay) are edited on the node that runs them.
### Capture (current)
mam-api `POST /recorders/:id/start` pre-creates a `live` asset and dispatches
`POST /sidecar/start` to the recorder's node-agent, which spawns a
`wild-dragon-capture:latest` container (host network, privileged,
/dev/blackmagic bound). The capture ffmpeg:
- input: `-f decklink -i "DeckLink Duo (N)"`
- filter: `yadif` (CPU deinterlace)
- output 0 (master): `prores_ks` (CPU) -> S3 (pipe) or growing SMB file
- output 1 (preview): `libx264` veryfast HLS -> /live/{assetId} (CPU)
DeckLink does capture (cheap); BOTH encodes are CPU. ~5 vCPU per 1080i signal
=> ~2 signals saturate the 12-vCPU VM. GPUs are idle during capture.
### Stop / finalize (working)
node-agent stops the sidecar with a **180s grace** (was 10s -> SIGKILL bug).
Capture's SIGTERM handler finalises the session and calls
`POST /assets/:id/finalize` (the live asset id passed as ASSET_ID), which flips
the asset out of `live`, records duration + S3 keys, and kicks the
proxy -> thumbnail -> filmstrip chain. (Earlier 409 bug: it used to POST a new
asset and collide with the live row.)
### Live monitor (working)
SDI HLS preview is a 2nd output of the capture ffmpeg (one DeckLink read ->
split -> ProRes + H.264 HLS), written to /live/{assetId} on the capture node.
node-agent serves GET /live/* over HTTP; mam-api proxies
GET /api/v1/recorders/:id/live/* to the recorder's node-agent; the web-ui
HlsPreview loads the proxied URL. Browser auth is the session cookie
(same-origin).
### GPU worker pool (working, post-capture)
BullMQ on shared Redis; queues are type-named (proxy/thumbnail/filmstrip/
conform/trim). Workers are capability-routed by `WORKER_QUEUES`, one GPU-pinned
container per card (`NVIDIA_VISIBLE_DEVICES` by UUID):
- HEAVY (proxy/conform/trim): Tesla P4 (zampp1) + L4 (zampp2), `h264_nvenc`.
- LIGHT (thumbnail/filmstrip): 2x Quadro P400 (zampp1).
DB setting `gpu_transcode_enabled=true` + `gpu_codec=h264_nvenc` enable NVENC.
Each worker stamps `WORKER_LABEL` onto job data -> Jobs UI "Node" column.
`RUN_PROMOTION=true` on exactly one worker runs the growing-files->S3 scan.
The worker GPU image is built from services/worker/Dockerfile.gpu (CUDA base +
Ubuntu ffmpeg with h264/hevc_nvenc; NO av1_nvenc).
### Deploy gotchas (learned)
- Service source is BAKED into images; edits need rebuild + recreate (or the
GPU image rebuild reuses cached layers so only final COPY changes -> fast).
- The capture image can only build on zampp2 (DeckLink SDK present there).
- Per-node `.env`: zampp2's REDIS_URL/DATABASE_URL/S3_* now point at zampp1
(.200); secrets live only in .env, never in committed compose.
- Clear all containers on both nodes before a full redeploy (user preference).
## 4. Target design
### 4.1 Capture ffmpeg gains NVENC
The capture image's custom FFmpeg 7.1 is currently built WITHOUT nvenc (only
prores_ks/dnxhd/libx264). Rebuild `services/capture/Dockerfile` ffmpeg with:
`--enable-cuda-nvcc --enable-libnpp --enable-nvenc --enable-cuvid` plus
nv-codec-headers (ffnvcodec) installed before configure. Keep `--enable-decklink`
and the existing codecs (ProRes stays available as a selectable fallback).
Verify `ffmpeg -encoders | grep nvenc` shows hevc_nvenc/h264_nvenc afterwards.
### 4.2 Capture sidecar gets a GPU
node-agent `handleSidecarStart` currently spawns the capture container with no
GPU. Add NVIDIA runtime + device pinning to the sidecar create spec:
`HostConfig.Runtime='nvidia'` (or DeviceRequests with the node's GPU) and env
`NVIDIA_VISIBLE_DEVICES=<uuid>` + `NVIDIA_DRIVER_CAPABILITIES=video,compute,utility`.
The capture node's GPU is shared with its worker-l4 (see capacity, §5).
### 4.3 Encode parameters (master)
All-Intra HEVC on NVENC:
`-c:v hevc_nvenc -preset p4 -rc vbr -g 1 -bf 0 -profile:v main10 -pix_fmt p010le` (10-bit 4:2:2 is not NVENC-native; NVENC HEVC is 4:2:0 8/10-bit.
If 4:2:2 mezzanine is required, that is a HARD blocker for NVENC and we stay on
ProRes for those feeds — see §8). Bitrate target tuned per format (1080i59.94
~100-160 Mbps to rival ProRes HQ). `-g 1 -bf 0` => every frame IDR (all-intra).
### 4.4 Container (growing-file)
Write the master to a growing file on the SMB share (GROWING_PATH), same path
the promotion worker already uploads on EOF. Container candidates, in order of
preference for Premiere growing-file mounts:
1. **MXF OP1a** (`-f mxf`) — broadcast standard, designed for growing/edit-while-
ingest; best Avid/Premiere support. HEVC-in-MXF support in Premiere is the
key unknown to validate (§8).
2. **Fragmented MOV/MP4** (`-movflags +frag_keyframe+empty_moov+default_base_moof`)
— no moov-at-EOF, readable while growing; fallback if MXF+HEVC is unsupported.
The HLS preview path is unchanged except it can also move to h264_nvenc now that
capture has NVENC (frees the last libx264 CPU cost).
## 5. Capacity & scaling (8 signals/machine)
After the move, per-signal CPU is just: DeckLink capture + yadif + mux + frame
upload to the GPU. The heavy HEVC encode is on NVENC. The constraint shifts from
CPU to **NVENC throughput + GPU memory + PCIe/host bandwidth**:
- The **L4 is a datacenter card => unlimited NVENC sessions** (no consumer
3-session cap). 8x 1080i HEVC-I encode sessions are well within an L4.
- GPU memory: ~8 concurrent 1080 NVENC sessions + frame buffers fit in 24 GB.
- The capture node's L4 is shared between capture (per-signal HEVC-I) and the
worker-l4 proxy jobs. Under 8-signal load, give capture priority; consider
moving worker-l4 (post-record proxies) to zampp1's P4 only, or gate worker-l4
intake while signals are live.
- yadif on CPU is still ~0.5-1 vCPU/signal; consider `yadif_cuda`/`bwdif_cuda`
(GPU deinterlace) once frames are uploaded to the GPU, keeping CPU near-idle.
**Node sizing:** a 12-vCPU VM was the ProRes wall; with GPU encode the same VM
should carry many more signals, but for 8x SDI + GPU + card passthrough prefer a
larger VM or bare metal with proper PCIe passthrough. Or spread signals across
multiple capture nodes (the node-agent model already supports N nodes; mam-api
routes each recorder to its node).
## 6. Multi-vendor capture (Blackmagic / Deltacast / AJA)
Today capture is hard-wired to `-f decklink`. Before three vendors accrue
special-cases, introduce a **source-backend abstraction** in capture-manager:
each backend returns ffmpeg input args + device discovery.
- **Blackmagic**: `-f decklink -i "<name>"` (current). Devices via
`ffmpeg -sources decklink`.
- **Deltacast**: VideoMaster SDK. No native ffmpeg demuxer upstream — needs an
SDK-backed capture (their SDK -> pipe to ffmpeg, or a small grabber). Plan a
`deltacast` backend that shells their tool into ffmpeg stdin (rawvideo).
- **AJA**: libajantv2. Also no upstream ffmpeg input; AJA ships `ntv2` capture
tools. Plan an `aja` backend feeding rawvideo into ffmpeg.
All backends converge on the SAME encode/output stage (HEVC-I NVENC + HLS), so
only the input differs. node-agent already binds the right /dev nodes per
sourceType (decklink/deltacast); extend for AJA.
## 7. Risks
- **4:2:2 / 10-bit chroma:** NVENC HEVC is 4:2:0 (8/10-bit). ProRes HQ is 4:2:2
10-bit. If a workflow REQUIRES 4:2:2 mezzanine, NVENC HEVC cannot match it and
those feeds stay on ProRes (CPU). Decide per-workflow.
- **Premiere growing HEVC support:** edit-while-record for HEVC-in-MXF (or frag
MOV) is unproven in our stack — this is the make-or-break validation (§8).
- **GPU contention** between live capture and post-record proxies on the same
L4; mitigate by prioritising capture / relocating proxy load.
- **Storage:** All-Intra HEVC bitrate ~ ProRes; expect similar disk usage.
- **Editor performance:** HEVC-I decode in Premiere is heavier than ProRes on
the edit workstation (decode cost moves to the editor). Validate scrubbing.
- **NVENC quality at all-intra** vs ProRes for archival; tune bitrate/preset.
## 8. Validation gate (do FIRST, before building the pipeline)
Prove the editor story on ONE channel before wiring 8:
1. Rebuild capture ffmpeg with NVENC; give the sidecar the L4.
2. Capture one DeckLink feed to All-Intra HEVC, writing a GROWING file to the
SMB share in (a) MXF OP1a, then (b) fragmented MOV.
3. While still recording, mount it in Premiere over SMB and confirm:
edit-while-record works, scrubbing is acceptable, audio in sync, file remains
valid after stop. Pick the container that works; if neither does, HEVC-I is
capture-only (no growing edit) and we keep ProRes for growing workflows.
## 9. Rollout
1. Validation gate (§8) on one channel.
2. Make capture codec/container a recorder setting; default growing feeds to
HEVC-I NVENC, keep ProRes selectable.
3. Move HLS preview to h264_nvenc.
4. Source-backend abstraction (§6) — land before Deltacast/AJA hardware.
5. GPU deinterlace + capacity test to 8 signals; finalise node sizing.

Binary file not shown.

View file

@ -1,73 +0,0 @@
# NLE Editor: React SPA Polish — Phases 13 Implementation Plan
> **Date:** 2026-05-24
> **Status:** Phase 1 IN PROGRESS
> **Progress:** Tasks 1.11.6 code-complete, pending test/deploy
---
## Phase 1 — Core Editor (IN PROGRESS)
### Task 1.1: Sequence API helpers in data.jsx ✅
- Added `getSequences`, `createSequence`, `getSequence`, `updateSequence`, `deleteSequence`, `syncSequenceClips`, `exportSequenceEDL` to `data.jsx`
- All exported on `window.ZAMPP_API`
### Task 1.2: Timecode.js wired into SPA ✅
- Added `<script src="js/timecode.js">` and `<script src="js/timeline.js">` to `index.html` before `screens-editor.jsx`
- `window.TC` available globally for 59.94 DF timecode math
### Task 1.3: TimelinePanel React component ✅
- `tlRef` container div in editor layout
- `useEffect` mounts `window.Timeline.init()` on first render
- `useEffect` pushes scale changes via `window.Timeline.setScale()`
- `onClipsChanged` / `onPlayheadMoved` callbacks connect timeline engine to React state
### Task 1.4: screens-editor.jsx rewrite ✅ (455 lines, was 162)
Full rewrite with:
- **App state**: `projectId`, `sequences`, `currentSeq`, `assets`, `sourceAsset`, `srcIn`/`srcOut`, `playheadFrames`, `history` (undo stack), `scale`, `tool`, `saveStatus`, `isDirty`
- **Source monitor**: `<video>` + `apiFetch('/assets/:id/stream')` + Mark In/Out + Insert button
- **Program monitor**: Virtual clip-by-clip playback — loads V1 clips sorted by `timeline_in_frames`, advances on `timeUpdate`/`ended` events
- **Media panel**: Asset list from `ZAMPP_DATA.ASSETS`, filter by bin, `AssetThumb` thumbnails, double-click loads source
- **Sequence management**: Picker `<select>`, "New sequence" modal, `openSequence()` loads via API
- **Auto-save**: `markDirty()` → debounce 2s → `syncSequenceClips()` → status updates
- **Undo/redo**: 50-step history stack, Ctrl+Z / Ctrl+Shift+Z
- **EDL export**: Button triggers `window.ZAMPP_API.exportSequenceEDL()`
- **Tool toolbar**: V/C/H buttons synced with `Timeline.setTool()`
- **Zoom slider**: Range input driving `window.Timeline.setScale()`
- **Keyboard handler**: I/O, V/C/H, Ctrl+Z/Shift+Z, Ctrl+S
### Task 1.5: "In Development" overlay removed ✅
- Deleted the `position: absolute; inset: 0; backdropFilter: blur(6px)` overlay div (was lines 98-117)
- Removed `FauxFrame` component reference
- All buttons are now functional
### Task 1.6: Editor nav badge removed ✅
- `shell.jsx`: `{ id: "editor", label: "Editor", icon: "editor" }` — no more `badge: { kind: "dev", text: "DEV" }`
### NEXT: Task 1.7 — Test & Deploy
1. `docker compose up -d --build web-ui`
2. Navigate to Editor route in browser
3. Verify: source monitor loads video, timeline renders with 4 track rows, Insert places clip, auto-save fires, EDL export downloads
---
## Phase 2 — UX Polish & Growing File (PENDING)
- [ ] 2.1 Multi-track refinements (ripple delete, snap, locking, overlap prevention)
- [ ] 2.2 Zoom slider + adaptive ruler
- [ ] 2.3 JKL transport + frame stepping
- [ ] 2.4 Waveform display on audio tracks
- [ ] 2.5 Inspector panel wiring
- [ ] 2.6 Style migration to Tailwind primitives
- [ ] 2.7 HLS live preview during capture
## Phase 3 — Export, Conform & Features (PENDING)
- [ ] 3.1 FCP XML export + conform queue
- [ ] 3.2 Hi-Res Auto-Relink
- [ ] 3.3 Timecoded Comments
- [ ] 3.4 Player Rebuild (P1)
- [ ] 3.5 Subclips (P2)
- [ ] 3.6 Multi-select & Bulk Ops (P3)
- [ ] 3.7 Smart Bins (P6)
- [ ] 3.8 Metadata Templates (P7)

File diff suppressed because it is too large Load diff

View file

@ -1,269 +0,0 @@
# Dragonflight User Authentication — Design
**Status:** Approved, ready for implementation planning
**Date:** 2026-05-27
**Brainstormed with:** Zac
## Problem
Dragonflight has the skeleton of an auth system spread across the codebase:
- `users` table (`id`, `username`, `password_hash`, `display_name`, `role`)
- `sessions` table (`sid`, `sess`, `expire`) for `connect-pg-simple`
- `groups`, `user_groups`, `api_tokens` tables
- `SESSION_SECRET` env var
- `AUTH_ENABLED` env flag with boot-log toggle
- PR #26 frontend handler that bounces to `/login.html` on 401
- Issue #94 "session security fixes" deployed 2026-05-26 (commit `3ebe5d6`)
But the actual `express-session` middleware was never mounted in `services/mam-api/src/index.js`. There is no `/api/v1/auth/*` router. There is no `requireAuth` middleware. As a result, when `AUTH_ENABLED=true` was tried:
1. User submits login, server returns 200 OK from a stub endpoint.
2. No `Set-Cookie` is ever sent (no session middleware mounted).
3. The next request to a protected route returns 401.
4. Frontend bounces to `/login.html`.
5. **Infinite redirect loop.**
The prior attempts failed because auth was being built reactively in pieces, with no single source of truth for what "logged in" means.
## Goals
- One coherent, readable auth code path.
- Web UI logins survive page reloads and container restarts.
- Premiere panel can authenticate via long-lived bearer tokens.
- First-run setup works on a fresh install with no env var or CLI gymnastics.
- The whole auth flow can be exercised by automated tests, including a regression test for the redirect-loop failure mode.
## Non-goals (v1)
- MFA / TOTP.
- OAuth / OIDC delegation (Forgejo, Google, etc.).
- Per-project or per-recorder permissions. Flat access: logged in = full access.
- Email-based "forgot password" (no SMTP assumed; admin-reset only).
- Audit log of who-did-what (the `last_login_at` column is the minimum).
- Service-to-service auth for `node-agent` — keeps existing `019-node-token-binding` mechanism.
## Decisions
| Decision | Choice | Reasoning |
|---|---|---|
| Client surface | Web UI + Premiere panel | Two transports (cookies + bearer), one identity backend |
| Permission model | Flat (logged in = full access) | Small homogeneous operator population. `groups` / `user_groups` schemas stay inert. |
| Identity provider | Local username/password | On-prem broadcast operators won't tolerate OIDC roundtrips. Matches existing schema. |
| First-user bootstrap | First-run setup page | Hardest to mis-configure. No env vars to leak. No CLI to remember. |
| Session lifetime | 8h absolute + 1h sliding idle | Operator security posture, tighter than typical SaaS. |
| Auth library | Hand-rolled (`express-session` + `connect-pg-simple`) | Explicit, debuggable. Rejected JWT and Passport for this codebase. |
## Architecture
### Single source of truth
"Logged in" means exactly one of two things:
1. The request carries a valid `dragonflight.sid` cookie whose row in `sessions` hasn't expired and isn't past its 1h-idle or 8h-absolute window, OR
2. The request carries `Authorization: Bearer <token>` whose SHA-256 matches an `api_tokens` row that hasn't been revoked or expired.
Nothing else counts. No `localStorage` flags, no JWT, no client-side "I think I'm logged in" hints.
### One middleware, one check
`services/mam-api/src/middleware/auth.js` exposes a single `requireAuth` function:
```js
export async function requireAuth(req, res, next) {
// Dev mode preserved. The 'dev' user is a real row in `users` seeded at
// boot when AUTH_ENABLED !== 'true', so FK-bearing routes (api_tokens,
// future comments, audit fields) keep working without conditional logic.
if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER; // { id: <UUID of seeded 'dev' user>, username: 'dev' }
return next();
}
// 1. Session check
if (req.session?.user_id) {
const now = Date.now();
if (now - req.session.first_seen_at > 8 * 3600 * 1000) return destroyAnd401(req, res);
if (now - req.session.last_seen_at > 1 * 3600 * 1000) return destroyAnd401(req, res);
req.session.last_seen_at = now;
req.user = await loadUser(req.session.user_id);
if (!req.user) return destroyAnd401(req, res);
return next();
}
// 2. Bearer check
const bearer = parseBearer(req.headers.authorization);
if (bearer) {
const hash = sha256hex(bearer);
const row = await pool.query(
`SELECT t.id, t.user_id, t.expires_at, u.username
FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1`, [hash]);
if (row.rows.length && (!row.rows[0].expires_at || row.rows[0].expires_at > new Date())) {
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [row.rows[0].id]).catch(() => {});
req.user = { id: row.rows[0].user_id, username: row.rows[0].username };
return next();
}
}
// 3. Otherwise
return res.status(401).json({ error: 'unauthorized' });
}
```
Mounted at the `/api/v1` level in `services/mam-api/src/index.js`, **before** the individual route mounts, with an allowlist for the three pre-login auth paths:
```js
app.use('/api/v1', (req, res, next) => {
const unauth = ['/auth/login', '/auth/setup', '/auth/setup-required'];
if (unauth.some(p => req.path === p)) return next();
return requireAuth(req, res, next);
});
// then: app.use('/api/v1/assets', assetsRouter), etc.
```
`/health` lives at the root, outside the `/api/v1` mount, so it's naturally unaffected. `/api/v1/cluster/*` keeps its existing `019-node-token-binding` service-auth path: requireAuth runs first, fails with 401 for an unauthenticated request, **but** the cluster routes themselves do their own token check on request bodies, so node-agent traffic must include a valid user session OR an api_token (which is the change — node-agent will need to be issued an api_token at install time). Alternative: carve `/api/v1/cluster/*` out of the requireAuth gate too, and keep node-agent on its existing binding token alone. Implementer should pick — flagged in the implementation order.
### Session middleware (actually wired this time)
In `services/mam-api/src/index.js`, **before any route**:
```js
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
const PgStore = connectPgSimple(session);
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
app.use(session({
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
secret: process.env.SESSION_SECRET,
name: 'dragonflight.sid',
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.TRUST_PROXY === 'true',
path: '/',
maxAge: 8 * 3600 * 1000,
},
rolling: false, // sliding renewal handled in requireAuth so we can enforce idle + absolute separately
resave: false,
saveUninitialized: false,
}));
```
### Auth router
`services/mam-api/src/routes/auth.js`:
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/v1/auth/setup-required` | none | `{ required: bool }`. Cheap, no auth. |
| `POST` | `/api/v1/auth/setup` | none | Only succeeds if `users` is empty. Creates first user, logs them in. |
| `POST` | `/api/v1/auth/login` | none | `{ username, password }` -> 200 + cookie or 401 |
| `POST` | `/api/v1/auth/logout` | required | Destroys session row, clears cookie |
| `GET` | `/api/v1/auth/me` | required | `{ id, username, display_name }` |
| `POST` | `/api/v1/auth/password` | required | Change own password (requires current) |
| `GET/POST/DELETE` | `/api/v1/auth/users[/:id]` | required | User CRUD |
| `GET/POST/DELETE` | `/api/v1/auth/tokens[/:id]` | required | Current user's API tokens |
### Data model
Existing schema is almost right. One small migration:
```sql
-- services/mam-api/src/db/migrations/023-auth-session-timestamps.sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
-- idle / absolute timestamps live inside session.sess JSONB; no schema change needed
```
`groups` and `user_groups` stay as-is, unused for v1. `api_tokens` is already correctly shaped.
## Flows
### Browser login (the one that broke last time)
1. SPA boots, `<AuthGate>` calls `GET /api/v1/auth/me`.
2. `requireAuth` returns 401.
3. AuthGate calls `GET /api/v1/auth/setup-required`. If `true`, render Setup screen. Otherwise, render Login screen.
4. User submits `POST /api/v1/auth/login`. Server `bcrypt.compare`s, sets `req.session.user_id`, `first_seen_at`, `last_seen_at`. **Critical:** `await new Promise(r => req.session.save(r))` before responding, so the cookie is persisted to Postgres before the next request can arrive.
5. AuthGate re-calls `/api/v1/auth/me`, gets 200, renders the app.
**Why this doesn't loop:** the explicit `req.session.save()` callback before response guarantees the cookie row exists before the SPA can fire its next request. `requireAuth` returns a clean 401 (not a redirect) so the SPA decides what to render. The static `/login.html` is deleted; there is no HTML bounce.
### Premiere panel bearer
1. Web UI -> Settings -> API Tokens -> "New token" named "Premiere panel".
2. `POST /api/v1/auth/tokens` returns `{ token: 'dfl_<32 hex>', prefix: 'dfl_a3f2', id }` **exactly once**.
3. Premiere panel sends `Authorization: Bearer dfl_<...>` on every request. `requireAuth` SHA-256s it, looks up `api_tokens.token_hash`, updates `last_used_at`.
### Idle + absolute timeout (inside `requireAuth`)
```
if session present:
if now - session.first_seen_at > 8h -> destroy session, 401
if now - session.last_seen_at > 1h -> destroy session, 401
session.last_seen_at = now
req.user = lookup(session.user_id)
next()
```
Bearer tokens have their own optional `expires_at` (`NULL` = never expires); checked the same way.
## Frontend
- **`services/web-ui/src/auth-gate.jsx`** — new component that wraps the SPA. On mount: `GET /me`. On 401: check `setup-required`, render either Setup or Login. On 200: render the app shell.
- **Login screen** — layout B from brainstorm: 22px wordmark over "WILD DRAGON BROADCAST" tagline above a `--bg-1` card containing username, password, "Sign in" button. Matches DESIGN.md tokens.
- **Setup screen** — same chrome; fields = username, password, confirm password; button = "Create admin".
- **Settings -> Account section** — change password.
- **Settings -> API Tokens section** — list / create / revoke. New token shown exactly once with a copy affordance.
- **Fetch wrapper** — the central `ZAMPP_API.fetch` (already exists) gains a 401 handler that re-mounts AuthGate's Login state with the current path saved as `last_path`, restored after re-auth.
### Removed
- The static `/login.html` page (PR #26's bounce target) is deleted. SPA handles login internally; no full-page reload.
## Error handling
| Case | Behavior |
|---|---|
| Wrong username or password | `401 { error: 'invalid credentials' }`. Same message either way, no user enumeration. |
| Login rate limiting | Per-IP exponential backoff (1s, 2s, 4s, 8s, max 30s). In-memory `Map`. Single-instance limitation documented. |
| Idle / absolute expiry | 401 -> AuthGate Login. Last path saved, restored on re-auth. |
| Setup after first user exists | `409 { error: 'setup already complete' }`. Permanently disabled. |
| Token revoke | `DELETE /api/v1/auth/tokens/:id` — only owner can revoke. Subsequent bearer requests 401. |
| Delete-self when only user | `409 { error: 'cannot delete last user' }`. |
| Forgot password | No self-serve. Any logged-in user can reset another via `POST /api/v1/auth/users/:id/password`. Documented as the recovery path. |
| Password rules | Min 12 chars, no max, no character class requirements (NIST SP 800-63B). `bcrypt` cost 12. |
| CSRF | `SameSite=Lax` + same origin + required `X-Requested-With: dragonflight-ui` header on mutating requests (belt-and-suspenders). |
| Session table growth | `connect-pg-simple` `pruneSessionInterval: 60 * 15` (every 15 min). |
## Testing
- **Unit — `services/mam-api/test/middleware/auth.test.js`**: requireAuth with (a) no creds, (b) valid session, (c) idle-expired session, (d) absolute-expired session, (e) valid bearer, (f) invalid bearer, (g) bearer matching a deleted user.
- **Integration — `services/mam-api/test/auth.integration.test.js`**: spin up Express + test Postgres. Walks: setup -> login -> /me -> mutating call -> logout -> /me 401. Second pass: idle timeout simulated by mutating `last_seen_at` in DB. Third pass: bearer issue -> use -> revoke -> 401.
- **Regression test for the redirect-loop bug:** explicit test that after `POST /auth/login` returns 200, a subsequent `GET /auth/me` with the returned cookie returns 200 in the same test client. This is the test that would have caught the original failure.
- **Manual smoke (documented in PR):** fresh install -> setup -> create admin -> land on dashboard -> reload (stays logged in) -> wait 1h idle -> reload -> bounce to login.
## Implementation order
Suggested sequencing for the implementation plan (writing-plans will refine):
1. Migration `023-auth-session-timestamps.sql`. Add idempotent seed of the dev user (`INSERT ... ON CONFLICT DO NOTHING` with a fixed UUID) so dev mode FK-bearing routes work out of the box.
2. `express-session` + `connect-pg-simple` wiring in `index.js`.
3. `requireAuth` middleware (with `DEV_USER` constant resolved from the seeded row).
4. Auth router (setup, login, logout, me, password).
5. Apply `requireAuth` to API router with allowlist. Decide cluster carve-out (see Architecture).
6. Auth tests (unit + integration + regression).
7. Frontend `<AuthGate>` + Login screen + Setup screen.
8. Frontend Settings -> Account + API Tokens.
9. Delete `/login.html`.
10. User CRUD + token CRUD routes.
11. Rate limiting + CSRF header.
12. Documentation: README updates, `AUTH_ENABLED` transition notes.
## Out-of-band notes for the implementer
- The current `cors({ origin: true, credentials: true })` in `index.js` is too permissive once cookies start carrying authority. Tighten to a specific origin list (driven by an `ALLOWED_ORIGINS` env var) at the same time as wiring the session middleware — otherwise we're undoing the `SameSite=Lax` protection from the other side.
- node-agent -> mam-api traffic on `/api/v1/cluster/*` must keep working. Add a route-level carve-out comment that this path uses the existing `019-node-token-binding` token, not the user-auth path.
- The boot log currently says `Authentication: ENABLED` / `DISABLED (set AUTH_ENABLED=true for production)`. Once this lands, the recommended default flips: `AUTH_ENABLED=true` becomes the documented default in `.env.example` and the README, and `AUTH_ENABLED=false` is documented as a dev-only escape hatch.

View file

@ -1,84 +0,0 @@
# HLS VOD Playback for Browser
Date: 2026-05-29 | Status: design → implementation
Authors: Zac + Claude
## Purpose
Replace the browser playback path for **recorded (VOD) assets** with HLS, retiring
the MP4 range-stitching workaround. The MP4 proxy is **kept** (supplements, not
replaces) because the Premiere UXP panel and conform pipeline consume it.
## Background — current state
- `GET /assets/:id/stream` returns `{ url: /api/v1/assets/:id/video, type: 'mp4' }`
for ready assets.
- `GET /assets/:id/video` streams `proxies/<id>.mp4` through Node with the
**RustFS range-stitching hack** (`stitchedS3Stream`): RustFS mis-serves ranged
GETs whose start offset is past ~5.8 MB, so the endpoint streams from byte 0 and
drops bytes. Works, but wastes bandwidth/CPU per seek and is fragile.
- **Live** assets already use HLS (`type: 'hls'`, `/live/<id>/index.m3u8`), and
`hls.js` is already loaded and wired in `screens-asset.jsx` for `type === 'hls'`.
- The proxy worker (`services/worker/src/workers/proxy.js`) produces a single
H.264/AAC/yuv420p MP4 — already HLS-compatible.
## Decisions
- **Supplement, not replace.** Keep `proxies/<id>.mp4`; add an HLS rendition.
- **Generate in the proxy worker** via fast remux (`-c copy`) — no re-encode.
- **Serve segments through mam-api** as whole-file GETs (no Range) — sidesteps the
RustFS range bug entirely and reuses session auth.
## Architecture
### 1. Generation (worker/proxy.js)
After uploading `proxies/<id>.mp4`, remux it to HLS into a temp dir:
```
ffmpeg -i <proxy.mp4> -c copy -f hls \
-hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \
-hls_segment_filename <tmp>/seg_%03d.ts <tmp>/index.m3u8
```
Upload every file in the temp dir to `hls/<assetId>/` (playlist + `.ts`). Set
`assets.hls_s3_key = 'hls/<assetId>/index.m3u8'`. Remux is seconds; failure is
non-fatal (MP4 path still works as fallback).
### 2. Storage / schema
Migration adds `assets.hls_s3_key TEXT` (nullable). Presence = HLS available.
Segment objects live under `hls/<assetId>/seg_NNN.ts`; playlist references
**relative** segment names so the serving endpoint is path-agnostic.
### 3. Serving (mam-api)
New `GET /assets/:id/hls/:file` (file = `index.m3u8` or `seg_NNN.ts`):
- Validate `:file` against `^(index\.m3u8|seg_\d+\.ts)$` (no traversal).
- Whole-object GET of `hls/<id>/<file>` from S3 — **no Range handling**.
- Content-Type: `application/vnd.apple.mpegurl` (m3u8) / `video/mp2t` (ts).
- `Cache-Control: private, max-age=3600` for segments; `no-cache` for the playlist.
- Covered by the existing `requireAuth` gate; `hls.js` carries the same-origin
session cookie (same mechanism the live HLS path already relies on).
### 4. Stream selection (mam-api `/stream`)
For non-live assets: if `hls_s3_key` is set →
`{ url: '/api/v1/assets/:id/hls/index.m3u8', type: 'hls' }`. Else fall back to the
existing MP4 `/video` response. Live unchanged.
### 5. Backfill (existing assets)
Add an `hls` BullMQ job + `POST /assets/:id/reprocess?type=hls`: downloads the
existing `proxy_s3_key`, remuxes to HLS, uploads, sets `hls_s3_key`. No re-encode.
### 6. Frontend
No change required — `screens-asset.jsx` already plays `type: 'hls'` via `hls.js`.
Verify `hls.js` xhr carries credentials (same-origin cookie) for the proxied
segments; add `xhrSetup` withCredentials only if needed.
## Out of scope
- Multi-bitrate/ABR ladders (single rendition for now).
- Replacing the MP4 proxy or the `/video` endpoint (kept as fallback + for panel).
- Live-asset playback changes (already HLS).
## Test plan
1. Upload/capture an asset → proxy job produces MP4 **and** `hls/<id>/index.m3u8`.
2. `/stream` returns `type: 'hls'`; `/assets/:id/hls/index.m3u8` → 200 m3u8;
`/assets/:id/hls/seg_000.ts` → 200 `video/mp2t`, whole-file (no 206/Range).
3. Browser: asset plays + seeks via hls.js (no range-stitching path hit).
4. `reprocess?type=hls` backfills an older asset; it then plays via HLS.
5. MP4 proxy + `/hires` download still work (panel workflow intact).

View file

@ -1,235 +0,0 @@
# Wild Dragon MAM — Playout / Master Control (MCR)
**Date:** 2026-05-30 (revised 2026-05-30 — §7 closed)
**Status:** APPROVED — implementation in progress (code drafted but uncommitted; see WORK_LOG_PLAYOUT.md)
**Author:** Zac + Claude
---
## Resolved Decisions
| Question | Decision |
|----------|----------|
| Playout engine | **CasparCG Server** (orchestrated via AMCP), not ffmpeg-native |
| Channel count | **Multi-channel from start** — N independent channels, placed across cluster nodes by capability (mirrors recorders) |
| Scheduling model | **Phased** — Phase A: on-demand playlist player; Phase B: 24/7 continuous channel |
| Output targets | SDI (DeckLink), NDI, SRT, RTMP — all via CasparCG consumers |
| Media source | Assets live in **S3**; must be staged to a CasparCG-local media volume before play (see §4) |
| CasparCG packaging | **Build our own image** (like `capture/build-with-decklink.sh`) — GL context via GPU passthrough or Xvfb; NDI + DeckLink SDKs fetched at build time (not redistributable) |
| Master codec playability | Zac confirms current masters **play fine in CasparCG** — no transcode-for-playout step; staging is a plain S3→/media copy. Validate on hardware but do not gate on it |
| Management UI | **Single Dragonflight `playout.html` GUI** drives everything via AMCP; operator never touches CasparCG directly |
---
## Overview
Playout adds **master-control-room** capability to Dragonflight: take library assets, arrange them on a timeline / playlist, and play them out continuously to a broadcast output — SDI via DeckLink, or stream via SRT / RTMP / NDI. Drag-and-drop scheduling, a program/preview monitor, and as-run logging.
This is the **mirror image** of the existing capture path. Capture is `input → ffmpeg encode → S3`. Playout is `S3 asset → engine → output`. We reuse three things wholesale:
1. **Cluster node + capability model** — nodes already advertise DeckLink/Deltacast/GPU in `cluster_nodes.capabilities`; channels are placed on nodes that have a free output port, exactly as recorders claim input ports.
2. **Sidecar orchestration** — mam-api spawns containers via the local Docker socket or the remote `node-agent /sidecar/start`. A CasparCG channel is just a different sidecar image.
3. **Scheduler tick + PG advisory lock**`src/scheduler.js` already runs a single-leader tick over a schedule table. Phase B's wall-clock channel reuses this pattern.
### Why CasparCG over ffmpeg-native
The capture stack proves we can drive ffmpeg + DeckLink. But playout's hard part is **gapless, frame-accurate, clean transitions between clips** — every clip boundary in an ffmpeg-per-clip model is a black flash unless we engineer a concat-feeder. CasparCG solves this natively: a channel is a persistent output with a playlist, hard/mix/wipe transitions, layered graphics/logo (CG), and DeckLink/NDI/SRT/RTMP consumers built in. We orchestrate it over **AMCP** (its TCP control protocol) instead of reinventing a feeder. Trade: a new dependency + container image, and media must be on a CasparCG-visible disk (§4).
---
## 1. Data Model
New migration `029-playout.sql`. Five tables.
### 1.1 `playout_channels`
A logical output. One channel → one engine instance → one output target.
```
id uuid pk
name text -- "Channel 1", "Pop-up SDI"
node_id uuid -> cluster_nodes(id) -- where the engine runs (null = primary)
output_type text -- 'decklink' | 'ndi' | 'srt' | 'rtmp'
output_config jsonb -- { device_index } | { ndi_name } | { url, latency } | { url, key }
video_format text -- '1080i5994' | '1080p5994' | '720p5994' ...
status text -- 'stopped' | 'starting' | 'running' | 'error'
container_id text -- running CasparCG sidecar
project_id uuid -> projects(id) -- RBAC scoping (nullable = admin-only)
created_at / updated_at
```
`output_type` + `output_config` map straight to a CasparCG consumer:
- `decklink``ADD <ch> DECKLINK <device> ...`
- `ndi``ADD <ch> NDI ...`
- `srt`/`rtmp` → `ADD <ch> FFMPEG <url> -f mpegts ...` (CasparCG 2.3+ ffmpeg consumer)
### 1.2 `playout_playlists`
An ordered list of items bound to a channel. Phase A's primary object.
```
id, channel_id -> playout_channels(id)
name, loop boolean, created_at / updated_at
```
### 1.3 `playout_items`
One entry on a playlist OR one entry on the 24/7 timeline.
```
id
playlist_id uuid -> playout_playlists(id) -- Phase A
asset_id uuid -> assets(id)
sort_order int -- position in playlist (Phase A)
scheduled_at timestamptz -- wall-clock start (Phase B, null in A)
in_point numeric -- seconds, trim head (reuse subclip in/out from editor)
out_point numeric -- seconds, trim tail
transition text -- 'cut' | 'mix' | 'wipe'
transition_ms int
graphics jsonb -- optional CG/template overlay (Phase B+)
media_status text -- 'pending' | 'staging' | 'ready' | 'error' (see §4)
media_path text -- resolved path inside the CasparCG media volume
```
### 1.4 `playout_schedule` (Phase B)
Day-ahead, wall-clock-bound timeline rows. Same shape as `playout_items` but `scheduled_at` is authoritative and the scheduler tick (§5) drives transitions. Phase A can ignore this table.
### 1.5 `playout_as_run`
Append-only log: what actually played, when, for how long. Compliance / billing.
```
id, channel_id, asset_id, item_id
started_at, ended_at, duration_s, result -- 'played' | 'skipped' | 'error'
```
---
## 2. Services & Components
### 2.1 New sidecar: `services/playout/` (CasparCG wrapper)
A thin container: **CasparCG Server** + a small Node control shim exposing HTTP, the same way `capture` wraps ffmpeg.
- Base image: official/community `casparcg/server` (Linux build with DeckLink + NDI + FFmpeg producers/consumers).
- Node shim (`src/index.js`): opens an AMCP TCP socket to local CasparCG, exposes:
- `POST /channel/start``ADD <ch> <consumer>` for the channel's output target
- `POST /play``PLAY <ch>-<layer> <media> [transition]`
- `POST /loadbg` + `/play` → preview/cue then take (preview monitor)
- `POST /stop`, `GET /status``INFO <ch>` (current clip, position, fps)
- playlist load → translate `playout_items` rows into a sequence of AMCP `LOADBG`/`PLAY` calls, advancing on `OnTransition` / end-of-clip events.
- Mirrors capture's status-polling contract so the UI monitor reuses existing plumbing.
### 2.2 mam-api: `src/routes/playout.js`
CRUD + control, RBAC-scoped via the existing `assertProjectAccess` helper (channels carry `project_id`).
```
GET /playout/channels list (project-filtered)
POST /playout/channels create (edit on project)
POST /playout/channels/:id/start|stop spawn/kill CasparCG sidecar
GET /playout/channels/:id/status proxy engine INFO
POST /playout/channels/:id/play|pause|skip transport control
GET/POST/PUT/DELETE /playout/playlists... playlist + item CRUD, reorder
POST /playout/items/:id/stage kick S3→media-volume staging (§4)
GET /playout/channels/:id/asrun as-run log
```
Channel start/stop reuses `resolveNodeTarget()` + the Docker-socket / `node-agent /sidecar/start` split already in `recorders.js`. **Refactor opportunity:** lift that sidecar-spawn logic out of `recorders.js` into `src/orchestration/sidecar.js` so both recorders and playout share it (keep this small — only what both need).
### 2.3 web-ui: `playout.html` + `public/playout.jsx`
New MCR page. Layout:
```
┌─ PREVIEW ───────────┬─ PROGRAM (on air) ──────┐
│ [cued clip] │ [live output] ● ON AIR │
│ TC / duration │ TC / remaining │
│ [CUE] [TAKE] │ [PLAY][PAUSE][SKIP][STOP]│
├─ MEDIA BIN ─────────┴──────────────────────────┤
│ (draggable asset list, reuse asset browser) │
├─ PLAYLIST / TIMELINE ──────────────────────────┤
│ ▸ clip A ──▸ clip B ──▸ clip C (drag-drop) │ Phase A: ordered list
│ └ 24h time grid w/ now-bar │ Phase B: time-of-day grid
└────────────────────────────────────────────────┘
```
- Drag-drop: reuse whatever the NLE editor timeline uses (check `editor.jsx`); assets drag from the bin into the playlist/grid.
- API via existing `ZAMPP_API.fetch` wrapper.
- Program monitor: HLS preview of the output — CasparCG can emit a second low-bitrate FFmpeg consumer to HLS, reusing the `/live/<id>` HLS plumbing capture already uses.
---
## 3. Channel placement & ports
A DeckLink port is exclusive — same constraint capture already handles. A node's DeckLink port can be an **input (recorder)** or an **output (playout channel)**, never both at once. So:
- Extend the capability/port-claim check: when starting a channel on `output_type=decklink`, verify the target node has that device index free (no active recorder, no active channel).
- NDI / SRT / RTMP outputs have no hardware contention → can stack many per node (GPU/CPU-bound only).
- Surface a unified "device map" (extend the existing cluster DeckLink-status endpoint) showing each port's role: idle / recording / playing-out.
---
## 4. Media staging (the S3 ⇄ CasparCG gap)
**The crux.** Assets live in S3 (`original_s3_key` / `proxy_s3_key`). CasparCG plays from a **local media folder**. Options:
- **A — Pre-stage to a shared media volume (recommended).** Before a clip can go on air, download/symlink it from S3 to a CasparCG-visible volume (`/media`), set `playout_items.media_status='ready'` + `media_path`. A new BullMQ `playout-stage` job (reuses the worker pattern) does the pull. UI shows per-item readiness; TAKE is blocked until `ready`. Mirrors the growing-file SMB share already mounted for capture.
- **B — Stream from S3 via presigned URL.** CasparCG FFmpeg producer plays an HTTPS presigned URL directly. Zero staging, but seeking/trim and gapless transitions over network are fragile for broadcast. Acceptable as a fallback for SRT/RTMP, risky for SDI.
**Decision:** Phase A uses **A** (stage proxies for preview, masters for air) with **B** available as a per-channel "low-latency / no-stage" toggle. Zac confirms the current masters play fine in CasparCG, so staging is a **plain S3→/media copy — no transcode-for-playout step**. (Validate on hardware during implementation, but the model does not assume a transcode stage.)
---
## 5. Scheduling
### Phase A — playlist player
No wall clock. Operator builds a `playout_playlists` row, drags items in, hits PLAY. The playout sidecar walks `playout_items` by `sort_order`, cueing the next clip during the current one (`LOADBG`) and taking it at end-of-clip with the configured transition. `loop` repeats. As-run logged per item.
### Phase B — 24/7 continuous channel
Wall-clock timeline in `playout_schedule`. Reuse `src/scheduler.js`:
- Add a second tick (or extend the existing one) under the **same PG advisory lock pattern** — exactly-one-leader, so a multi-replica deploy doesn't double-fire.
- Tick responsibilities: stage upcoming items (look-ahead window), verify the on-air item matches the schedule, **fill gaps** (loop a filler/slate asset when the timeline has a hole — a channel must never go black), roll the day forward.
- As-run becomes the compliance record.
---
## 6. Phasing / Milestones
**Phase A — Playlist playout MVP**
1. Migration `029-playout.sql` (channels, playlists, items, as-run).
2. `services/playout/` sidecar: CasparCG image + AMCP control shim, single output target (start with **SRT or NDI** — no hardware needed for dev; DeckLink behind hardware check).
3. mam-api `routes/playout.js` — channel + playlist CRUD, start/stop, transport, RBAC.
4. `playout-stage` BullMQ job (S3 → /media).
5. web-ui `playout.html` — bin + drag-drop ordered playlist + program/preview monitors + transport.
6. DeckLink output on real hardware; port-contention check vs recorders.
**Phase B — 24/7 continuous channel**
7. `playout_schedule` + time-of-day grid UI.
8. Scheduler tick (advisory-locked) — look-ahead staging, gap-fill/slate, day-roll.
9. As-run reporting view.
10. Graphics/CG overlay (logo bug, lower-thirds) via CasparCG templates.
---
## 7. Open Questions (for review)
**Resolved (2026-05-30):**
- ~~CasparCG packaging~~**build our own image.** Fetch DeckLink + NDI SDKs at build time (not redistributable — same as capture's DeckLink build). GL context for the mixer comes from GPU passthrough on a real node, or **Xvfb** (virtual framebuffer) where there's no display — community images run `--privileged` + X11 socket. Pin the NDI SDK version to what the server expects (`.so` version mismatch is the common docker failure).
- ~~Master codec playability~~ → Zac confirms masters **play fine**; no transcode-for-playout. Staging = plain S3→/media copy.
- ~~Management GUI~~**single Dragonflight `playout.html`** drives everything via AMCP; operator never touches CasparCG.
- ~~Audio loudness~~**pre-normalize at stage time** (Zac, 2026-05-30). `playout-stage` job runs ffmpeg `loudnorm` (EBU R128, target 23 LUFS, true-peak 1 dBTP) once, on the S3→/media copy. Output is the cached version CasparCG plays. Staging is no longer a pure copy — staging cost ≈ realtime CPU per clip on first stage; results are reusable across channels. Override (`media_status='ready'` + raw copy) available for clips already mastered to spec.
- ~~Frame rate~~**`1080p5994`** default for new channels (Zac, 2026-05-30). Progressive 1080 @ 59.94 fps. Per-channel override allowed (`video_format` column). Streaming-friendly (SRT/RTMP/NDI) and current SDI gear accepts it; matches capture's 59.94 cadence.
- ~~Preview latency~~**HLS v1** (Zac, 2026-05-30). Reuse capture's `/live/<id>` HLS plumbing. CasparCG emits a second low-bitrate FFmpeg consumer to HLS. ~46s lag, fine for confidence monitor. Operator desk gets a real downstream monitor off the SDI/NDI output anyway. Revisit WebRTC if MCR operators complain.
- ~~Failover~~**auto-restart on healthy node** (Zac, 2026-05-30). Scheduler tick (§5) monitors `playout_sidecars` health (AMCP ping + container alive); on N missed checks marks the channel `error`, re-places it on another capability-matching node with a free output port, resumes the playlist from the next item after the as-run-logged on-air item. Gap = black/slate for ~530 s during respawn (operator sees a flag in the UI). **DeckLink channels are not auto-failed-over in v1** — device-index pinning makes the destination port non-trivial; v1 alerts and lets the operator move the channel. NDI/SRT/RTMP channels (no hardware contention) failover automatically. Tracked via `restart_count` + `last_restart_at` on `playout_channels`.
**Still open:**
- (none — all §7 questions resolved 2026-05-30)
---
## 8. Reused building blocks (already in the repo)
| Need | Existing piece |
|------|----------------|
| Spawn engine container local/remote | `recorders.js` Docker-socket + `node-agent /sidecar/start` |
| Node capability / port model | `cluster_nodes.capabilities`, cluster DeckLink-status endpoint |
| Single-leader scheduled transitions | `src/scheduler.js` + PG advisory lock |
| Background media jobs | BullMQ worker (`services/worker`) |
| RBAC scoping | `src/auth/authz.js` `assertProjectAccess` (channel/project_id) |
| HLS preview plumbing | capture's `/live/<id>` HLS output |
| Subclip in/out points | NLE editor in/out marking |
| API wrapper / SPA shell | `ZAMPP_API.fetch`, esbuild JSX pages |

View file

@ -1,10 +1,4 @@
# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ─────────
# All-Intra HEVC NVENC is the master codec for growing-file ingest (see
# docs/design/2026-05-29-all-intra-hevc-ingest.md). This stage gets the
# nv-codec-headers (header-only, no driver / no full CUDA toolkit needed)
# so ffmpeg's configure can light up hevc_nvenc / h264_nvenc / cuvid.
# At runtime, /dev/nvidia* + the host driver libs (via the NVIDIA Container
# Toolkit) supply the actual encoder.
# ── Stage 1: Build FFmpeg with DeckLink support ─────────────────────────────
FROM debian:bookworm AS ffmpeg-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
@ -19,11 +13,6 @@ COPY sdk/ /decklink-sdk/
COPY patch_decklink.py /patch_decklink.py
COPY decklink-sdk16.patch /decklink-sdk16.patch
# nv-codec-headers — just the ffnvcodec public headers + a pkg-config file.
# Pin to a tag known to work with FFmpeg 7.1 (n12.x series).
RUN git clone --depth=1 --branch n12.1.14.0 https://github.com/FFmpeg/nv-codec-headers.git /nv-codec-headers \
&& make -C /nv-codec-headers PREFIX=/usr/local install
# Pull FFmpeg 7.1 source
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
@ -31,15 +20,8 @@ RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /
RUN python3 /patch_decklink.py
WORKDIR /ffmpeg
# NVENC adds: --enable-nvenc (encoder), --enable-cuvid (decoder), --enable-ffnvcodec.
# We deliberately do NOT enable --enable-cuda-nvcc / --enable-libnpp here — those
# require the full ~3GB CUDA toolkit and are only needed for GPU filters like
# yadif_cuda / scale_cuda. If §5's GPU deinterlace stretch goal goes ahead,
# rebuild this image off nvidia/cuda:12.x-devel and flip those flags on.
RUN ./configure \
--prefix=/usr/local \
--extra-cflags="-I/decklink-sdk -I/usr/local/include" \
--extra-ldflags="-L/usr/local/lib" \
--enable-gpl \
--enable-nonfree \
--enable-libx264 \
@ -50,20 +32,13 @@ RUN ./configure \
--enable-libsrt \
--enable-libzmq \
--enable-decklink \
--enable-ffnvcodec \
--enable-nvenc \
--enable-cuvid \
--extra-cflags="-I/decklink-sdk" \
--disable-doc \
--disable-debug \
--disable-ffplay \
&& make -j$(nproc) \
&& make install
# Sanity-check: hevc_nvenc and h264_nvenc must be present in the encoder list,
# otherwise the resulting image is useless for the All-Intra HEVC pipeline.
RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|| (echo 'FATAL: nvenc encoders missing from ffmpeg build' && exit 1)
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
FROM node:20-bookworm
@ -83,11 +58,6 @@ COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
RUN ldconfig
# Mount points the recorder lifecycle expects to exist.
# /live — HLS preview output (bound from host LIVE_DIR by node-agent)
# /growing — growing-file master output (bound from host /mnt/NVME/MAM/growing)
RUN mkdir -p /live /growing
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev

View file

@ -28,32 +28,7 @@ const VIDEO_CODECS = {
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
// All-Intra HEVC on NVENC — the growing-file master codec.
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
// to its last complete frame — the prerequisite for edit-while-record.
//
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
// is rejected with EINVAL (validated on the L4, driver 595). The working
// recipe for true all-intra is therefore:
// -bf 0 no B-frames
// -g 600 large GOP just to satisfy the init check
// -forced-idr 1 forced keyframes are emitted as IDR
// -force_key_frames expr:1 force a keyframe on EVERY frame
// → ffprobe confirms pict_type = I for all frames.
//
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
// The frag-MOV index is not deferred to EOF, so the file stays readable while
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
//
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
hevc_nvenc: {
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
bitrateControl: true,
pixFmt: 'p010le',
},
hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
};
const AUDIO_CODECS = {
@ -153,81 +128,25 @@ class CaptureManager {
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
}
// Deltacast SDI via VideoMaster SDK FFmpeg plugin.
// FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
// When the physical device is absent (/dev/deltacast<N> missing), fall back
// to a lavfi test card so development and integration testing work without hardware.
if (sourceType === 'deltacast') {
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
? parseInt(device, 10)
: 0;
const { existsSync } = await import('node:fs');
const deviceNode = `/dev/deltacast${idx}`;
if (existsSync(deviceNode)) {
console.log(`[capture] Deltacast index ${idx}${deviceNode} (hardware)`);
return {
inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
isNetwork: false,
};
} else {
// No hardware — lavfi test card with port label + timecode burn-in.
// Matches the deltacast-sdi-recorder standalone app fallback exactly so
// recorded files look right in the MAM library during dev.
console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
const testSrc = [
`testsrc2=size=1920x1080:rate=30`,
`drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
`drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
].join(',');
return {
inputArgs: [
'-f', 'lavfi', '-i', testSrc,
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
'-map', '0:v:0', '-map', '1:a:0',
],
isNetwork: false,
};
}
}
// Default: SDI via DeckLink
// device may be an integer index (0-based) or a full device name string.
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo (2)').
// Map integer index -> name using ffmpeg -sources decklink at runtime.
//
// ffmpeg -sources decklink output format:
// Auto-detected sources for decklink:
// DeckLink Duo 2
// DeckLink Duo 2 (2)
// Lines containing device names start with whitespace; the header line
// starts with a non-space character. Previous code used a v4l2-style
// hex-address regex that never matched DeckLink output → index 1+ always
// fell through to a wrong fallback, producing black output from port 2+.
let deckLinkName = String(device);
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
const idx = parseInt(device, 10);
try {
const { execSync } = await import('child_process');
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const out = execSync('ffmpeg -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const names = [];
for (const line of out.split('\n')) {
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (m) names.push(m[1]);
}
if (names[idx]) {
deckLinkName = names[idx];
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
} else {
// Fallback: cannot determine model name without enumeration.
// Log a warning — operator should check the detected device list.
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
deckLinkName = `DeckLink (${idx})`;
}
} catch (err) {
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
// Pass the numeric index directly; some ffmpeg builds accept it.
deckLinkName = String(device);
if (names[idx]) deckLinkName = names[idx];
else deckLinkName = `DeckLink Duo (${idx + 1})`;
} catch (_) {
deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`;
}
}
return {
@ -291,16 +210,12 @@ class CaptureManager {
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
}
// DeckLink hardware does NOT support concurrent capture from the same port.
// Opening a second ffmpeg process on the same DeckLink input while the first
// is already capturing causes "Cannot Autodetect input stream or No signal"
// on the second process — making the proxy empty and potentially crashing the
// container before the hires upload completes.
//
// Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
// worker generate the proxy from the hires master after the recording stops.
// The stop handler sets needsProxy=true so the worker picks it up.
const proxyKey = null;
// Network sources cannot be opened by two FFmpeg processes simultaneously
// (one socket = one consumer). For SRT/RTMP the BullMQ worker generates
// the proxy after the recording stops.
const proxyKey = (sourceType === 'sdi' && proxyEnabled)
? `projects/${projectId}/proxies/${clipName}.${proxyExt}`
: null;
const startedAt = new Date().toISOString();
@ -326,38 +241,12 @@ class CaptureManager {
const hiresOutput = growingPath ? growingPath : 'pipe:1';
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
// For SDI we cannot open the DeckLink device a second time for a preview
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
let sdiHlsDir = null;
let hiresArgs;
if (sourceType === 'sdi' && this._assetIdForHls) {
const fsMod = await import('node:fs');
sdiHlsDir = '/live/' + this._assetIdForHls;
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
hiresArgs = [
...inputArgs,
'-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
// Output 0 — ProRes master (S3 pipe or growing file)
'-map', '[vhi]', '-map', '0:a:0?',
...hiresCodecArgs,
hiresOutput,
// Output 1 — low-latency H.264 HLS preview for the UI monitor
'-map', '[vlo]', '-map', '0:a:0?',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
sdiHlsDir + '/index.m3u8',
];
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
} else {
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
}
const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
const hiresProcess = spawn('ffmpeg', [
...inputArgs,
...sdiFilterArgs,
...hiresCodecArgs,
hiresOutput,
], { stdio: hiresStdio });
const hiresUpload = growingPath
? Promise.resolve({ growingPath })
@ -409,8 +298,39 @@ class CaptureManager {
}
});
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
// DeckLink hardware does not support two concurrent readers on the same port.
// SDI only: spawn a second ffmpeg for the proxy.
// DeckLink cards allow concurrent reads; network sockets do not.
if (!isNetwork && proxyEnabled) {
const proxyCodecArgs = buildEncodeArgs({
codec: proxyVideoCodec,
videoBitrate: proxyVideoBitrate,
framerate: proxyFramerate,
audioCodec: proxyAudioCodec,
audioBitrate: proxyAudioBitrate,
audioChannels: proxyAudioChannels,
container: proxyContainer,
isNetwork: false,
isProxy: true,
});
console.log('[capture] proxy ffmpeg args:', proxyCodecArgs.join(' '));
const proxyProcess = spawn('ffmpeg', [
...inputArgs,
...sdiFilterArgs,
...proxyCodecArgs,
'-movflags', '+frag_keyframe+empty_moov',
'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] });
const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout);
processes.proxy = proxyProcess;
uploads.proxy = proxyUpload;
proxyProcess.stderr.on('data', (data) => {
console.error(`[PROXY] ${data}`);
});
}
this.state.recording = true;
this.state.sessionId = sessionId;

View file

@ -9,7 +9,6 @@ dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
const MAM_API_TOKEN = process.env.MAM_API_TOKEN || '';
app.use(cors());
app.use(express.json());
@ -129,33 +128,17 @@ async function gracefulShutdown(signal) {
try {
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
headers: { 'Content-Type': 'application/json' },
});
} catch (e) {
console.error('[shutdown] failed to flag empty asset:', e.message);
}
}
} else if (liveAssetId) {
// Finalise the pre-created live asset by id (avoids POST / 409 collision).
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/finalize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
body: JSON.stringify({ hiresKey: completed.hiresKey, proxyKey: completed.proxyKey, duration: completed.duration }),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api finalize returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] live asset finalised with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to finalise asset:', mamErr.message);
}
} else {
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,

View file

@ -1,6 +1,5 @@
import express from 'express';
import { execSync, spawn } from 'child_process';
import { existsSync, readdirSync } from 'node:fs';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
@ -96,8 +95,8 @@ router.get('/devices', (req, res) => {
output = error.stderr ? error.stderr.toString() : error.toString();
}
// Parse ffmpeg output for DeckLink device names.
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
// Parse ffmpeg output for DeckLink device names
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
const lines = output.split('\n');
let deviceIndex = 0;
@ -119,57 +118,6 @@ router.get('/devices', (req, res) => {
}
});
/**
* GET /devices/deltacast
* List available Deltacast ports.
* Reads /dev/deltacast<N> nodes; falls back to env DELTACAST_PORT_COUNT
* so nodes without hardware still report their configured port count
* (test-card mode).
*/
router.get('/devices/deltacast', (req, res) => {
try {
const devices = [];
// First: enumerate actual /dev/deltacast* device nodes.
try {
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
devEntries.sort();
for (const entry of devEntries) {
const m = entry.match(/^deltacast(\d+)$/);
if (m) {
devices.push({
index: parseInt(m[1], 10),
name: `Deltacast Port ${m[1]}`,
device: `/dev/${entry}`,
present: true,
});
}
}
} catch (_) { /* /dev always exists; ignore */ }
// Second: if DELTACAST_PORT_COUNT env is set and larger than what we found,
// fill in the remaining slots as test-card entries (no physical device).
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
const found = new Set(devices.map(d => d.index));
for (let i = 0; i < envCount; i++) {
if (!found.has(i)) {
devices.push({
index: i,
name: `Deltacast Port ${i} (test card)`,
device: `/dev/deltacast${i}`,
present: false,
});
}
}
devices.sort((a, b) => a.index - b.index);
res.json({ devices });
} catch (error) {
console.error('Error listing Deltacast devices:', error);
res.status(500).json({ error: 'Failed to list Deltacast devices' });
}
});
/**
* GET /status
* Get current capture status
@ -202,28 +150,6 @@ router.post('/probe', async (req, res) => {
}
}
if (source_type === 'deltacast') {
// Enumerate /dev/deltacast* nodes; report present/absent per index.
try {
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
const found = devEntries.map(n => {
const m = n.match(/^deltacast(\d+)$/);
return { index: parseInt(m[1], 10), device: `/dev/${n}`, present: true };
});
const foundIdx = new Set(found.map(d => d.index));
for (let i = 0; i < envCount; i++) {
if (!foundIdx.has(i)) {
found.push({ index: i, device: `/dev/deltacast${i}`, present: false });
}
}
found.sort((a, b) => a.index - b.index);
return res.json({ ok: true, source_type, devices: found });
} catch (err) {
return res.json({ ok: false, source_type, error: err.message });
}
}
if (listen) {
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
}

65
services/editor/.gitignore vendored Normal file
View file

@ -0,0 +1,65 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
.next/
out/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
coverage/
.nyc_output/
# Temporary files
*.tmp
.cache/
.temp/
.docs/
docs/
# Project-specific
/public/projects/
*.openreel
apps/cloud/
apps/ios
apps/android
# Local files
FEATURES_TWITTER.md
.claude-tasks.md
CLAUDE.md
*-PLAN.md
*-PLAN-*.md
.playwright-mcp/
.wrangler/
mobile-mockup/

2
services/editor/.serena/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/cache
/project.local.yml

View file

@ -0,0 +1,135 @@
# the name by which the project can be referenced within Serena
project_name: "openreel-video"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []

View file

@ -0,0 +1,387 @@
# Contributing to OpenReel
Thank you for your interest in contributing to OpenReel! This document provides guidelines and instructions for contributing.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Standards](#coding-standards)
- [Making Changes](#making-changes)
- [Testing](#testing)
- [Submitting Changes](#submitting-changes)
## Code of Conduct
Be respectful, constructive, and professional. We're building something great together!
## Getting Started
### Prerequisites
- Node.js 18 or higher
- pnpm (recommended) or npm
- Git
- Modern browser with WebCodecs support (Chrome 94+, Edge 94+)
### Development Setup
```bash
# 1. Fork and clone the repository
git clone https://github.com/Augani/openreel-video.git
cd openreel-video
# 2. Install dependencies
pnpm install
# 3. Start development server
pnpm dev
# 4. Open browser to http://localhost:5173
```
## Project Structure
```
openreel/
├── apps/
│ └── web/ # Main web application
│ ├── public/ # Static assets
│ └── src/
│ ├── components/ # React components
│ ├── stores/ # State management (Zustand)
│ ├── bridges/ # Core engine bridges
│ └── services/ # Business logic
├── packages/
│ └── core/ # Shared core logic
│ ├── src/
│ │ ├── actions/ # Action system
│ │ ├── video/ # Video processing
│ │ ├── audio/ # Audio processing
│ │ ├── graphics/ # Graphics & SVG
│ │ ├── text/ # Text & titles
│ │ └── export/ # Export engine
│ └── types/ # TypeScript types
```
## Coding Standards
### TypeScript
- **Strict mode**: Always use TypeScript strict mode
- **Types**: Prefer interfaces over types for object shapes
- **No `any`**: Avoid `any` - use `unknown` or proper types
- **Naming**:
- Components: `PascalCase` (e.g., `Timeline`, `Preview`)
- Functions: `camelCase` (e.g., `handleClick`, `processVideo`)
- Constants: `UPPER_SNAKE_CASE` (e.g., `MAX_DURATION`)
- Files: `kebab-case.tsx` or `PascalCase.tsx` for components
### Code Style
```typescript
// ✅ Good
interface VideoClip {
id: string;
duration: number;
startTime: number;
}
function processClip(clip: VideoClip): ProcessedClip {
if (!clip.id) {
throw new Error('Clip ID is required');
}
return {
...clip,
processed: true,
};
}
// ❌ Avoid
function processClip(clip: any) {
console.log('Processing...'); // Remove debug logs
const result = clip; // Unclear what's happening
return result;
}
```
### React Components
```typescript
// ✅ Good
interface TimelineProps {
tracks: Track[];
onClipSelect: (clipId: string) => void;
}
export const Timeline: React.FC<TimelineProps> = ({ tracks, onClipSelect }) => {
const handleClick = useCallback((id: string) => {
onClipSelect(id);
}, [onClipSelect]);
return (
<div className="timeline">
{tracks.map(track => (
<Track key={track.id} track={track} onClick={handleClick} />
))}
</div>
);
};
```
### Comments
- **Do**: Comment complex algorithms and business logic
- **Don't**: Comment obvious code
- **Do**: Add JSDoc for public APIs
- **Don't**: Leave TODO comments without issues
```typescript
// ✅ Good - Explains WHY
// Use binary search for O(log n) performance on large timelines
const clipIndex = binarySearch(clips, targetTime);
// ❌ Bad - States the obvious
// Loop through clips
for (const clip of clips) { }
// ✅ Good - Public API documentation
/**
* Applies a filter to a video clip
* @param clipId - The clip identifier
* @param filter - Filter configuration
* @returns Updated clip with filter applied
*/
export function applyFilter(clipId: string, filter: Filter): Clip {
// ...
}
```
## Making Changes
### 1. Create a Branch
```bash
# Feature branch
git checkout -b feat/add-transition-effects
# Bug fix branch
git checkout -b fix/timeline-scroll-bug
# Documentation
git checkout -b docs/update-contributing-guide
```
### 2. Make Your Changes
- Write clean, self-documenting code
- Follow the existing code style
- Keep commits focused and atomic
- Write meaningful commit messages
### 3. Commit Messages
Follow conventional commits:
```
feat: add crossfade transition effect
fix: resolve timeline scrubbing lag
docs: update API documentation
refactor: simplify video processing pipeline
test: add tests for audio mixer
perf: optimize waveform rendering
```
### 4. Keep Your Branch Updated
```bash
git fetch origin
git rebase origin/main
```
## Testing
### Running Tests
```bash
# Run all tests (watch mode)
pnpm test
# Run tests once (CI mode)
pnpm test:run
# Type checking
pnpm typecheck
# Linting
pnpm lint
```
### Writing Tests
```typescript
import { describe, it, expect } from 'vitest';
import { processClip } from './clip-processor';
describe('processClip', () => {
it('should process a valid clip', () => {
const clip = { id: '123', duration: 10, startTime: 0 };
const result = processClip(clip);
expect(result.processed).toBe(true);
expect(result.id).toBe('123');
});
it('should throw error for invalid clip', () => {
const clip = { id: '', duration: 10, startTime: 0 };
expect(() => processClip(clip)).toThrow('Clip ID is required');
});
});
```
## Submitting Changes
### 1. Push Your Branch
```bash
git push origin feat/your-feature-name
```
### 2. Create a Pull Request
1. Go to GitHub and create a pull request
2. Fill out the PR template:
- **Description**: What does this PR do?
- **Motivation**: Why is this change needed?
- **Testing**: How was this tested?
- **Screenshots**: For UI changes
- **Breaking Changes**: Any breaking changes?
### 3. PR Template
```markdown
## Description
Brief description of changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
- [ ] Tested locally
- [ ] Added/updated tests
- [ ] All tests passing
## Screenshots (if applicable)
[Add screenshots for UI changes]
## Checklist
- [ ] Code follows project style guidelines
- [ ] Self-review completed
- [ ] Comments added for complex code
- [ ] Documentation updated
- [ ] No console.log or debug code left
- [ ] Tests pass
```
### 4. Code Review Process
- Respond to feedback promptly
- Make requested changes
- Push updates to the same branch
- Re-request review when ready
## Areas to Contribute
### 🐛 Bug Fixes
- Check [Issues](https://github.com/Augani/openreel-video/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
- Reproduce the bug
- Write a failing test
- Fix the bug
- Verify the test passes
### ✨ New Features
- Discuss in [Discussions](https://github.com/Augani/openreel-video/discussions) first
- Get approval before large changes
- Break into smaller PRs if possible
- Update documentation
### 📖 Documentation
- Fix typos and errors
- Add examples
- Improve clarity
- Add tutorials
### 🎨 Effects & Presets
- Create new video effects
- Add transition effects
- Build color grading presets
- Contribute templates
### 🧪 Testing
- Add missing tests
- Improve test coverage
- Add integration tests
- Performance testing
### 🌍 Translation
- Add new language support
- Improve existing translations
- Fix translation errors
## Development Tips
### Hot Reload
Changes to React components hot reload automatically. For core engine changes, you may need to refresh.
### Debugging
```typescript
// Use browser DevTools
// Set breakpoints in TypeScript source
// Check Network tab for media loading
// Use Performance profiler for optimization
```
### Performance
- Profile before optimizing
- Use Web Workers for heavy processing
- Leverage WebCodecs API for video
- Cache expensive computations
- Use useMemo/useCallback appropriately
### Common Issues
**Issue**: Video won't play
- Check browser support for WebCodecs
- Verify codec support
- Check browser console for errors
**Issue**: Build fails
- Clear node_modules and reinstall
- Check Node.js version (18+)
- Verify pnpm version
**Issue**: Tests fail
- Try running `pnpm test:run` for a single run
- Check for console errors
- Verify test environment setup
- Run `pnpm typecheck` to check for type errors
## Questions?
- **Discord**: [Join our Discord](https://discord.gg/openreeel)
- **Discussions**: [GitHub Discussions](https://github.com/Augani/openreel-video/discussions)
- **Email**: contribute@openreeel.video
## Recognition
Contributors are recognized in:
- README.md contributors section
- GitHub contributors page
- Release notes for significant contributions
Thank you for contributing to OpenReel! 🎬

View file

@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++ git bash
RUN corepack enable && corepack prepare pnpm@9.7.0 --activate
WORKDIR /build
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json mediabunny.d.ts ./
COPY apps ./apps
COPY packages ./packages
RUN pnpm install --frozen-lockfile=false
RUN pnpm build:wasm || echo "no wasm build step, continuing"
RUN pnpm --filter @openreel/web build
RUN ls -la apps/web/dist
FROM nginx:alpine AS runtime
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
COPY --from=builder /build/apps/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,20 @@
# Z-AMPP <-> openreel-video integration
Vendored from https://github.com/Augani/openreel-video (MIT). The upstream .git directory was removed so this lives as plain source we can patch freely.
## Files added (Z-AMPP-only, not upstream)
- Dockerfile, nginx.conf, VENDOR.txt, INTEGRATION.md
- apps/web/src/mam-bridge.ts: boot hook + pickFromMAM() modal
- packages/core/src/export/mam-export-target.ts: helpers for upload-to-MAM
## Upstream files patched
- apps/web/package.json: build script changed `tsc --noEmit && vite build` -> `vite build`. Original preserved as build:strict. (upstream tsc fails on pre-existing WebGPU + import.meta errors.)
- apps/web/src/bridges/media-bridge.ts: appended importFromURL(url, name, contentType?) as the last method of the MediaBridge class.
- apps/web/src/main.tsx: appended `import "./mam-bridge";` so the bridge boot hook runs.
## Query params honored
- ?asset=<uuid> auto-imports that asset on load.
- ?project=<uuid> stored in localStorage.mamProjectId for save-to-MAM.
## Ports
Container exposes 80; compose maps ${PORT_EDITOR:-47435}:80.

21
services/editor/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Augustus Otu and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

308
services/editor/README.md Normal file
View file

@ -0,0 +1,308 @@
# OpenReel Video
> **The open source CapCut alternative. Professional video editing in your browser. No uploads. No installs. 100% open source.**
OpenReel Video is a fully-featured browser-based video editor that runs entirely client-side. Built with React, TypeScript, WebCodecs, and WebGPU for professional-grade video editing without the need for expensive software or cloud processing.
**[Try it Live](https://openreel.video)** | **[Documentation](CONTRIBUTING.md)** | **[Discussions](https://github.com/Augani/openreel-video/discussions)** | **[Twitter](https://x.com/python_xi)**
![OpenReel Editor](https://img.shields.io/badge/Lines%20of%20Code-130k+-blue) ![License](https://img.shields.io/badge/License-MIT-green) ![Status](https://img.shields.io/badge/Status-Beta-orange) ![Open Source](https://img.shields.io/badge/Open%20Source-100%25-brightgreen)
---
## Why OpenReel?
- **100% Client-Side** - Your videos never leave your device. No uploads, no cloud processing, complete privacy.
- **No Installation** - Works in Chrome/Edge. Just open and start editing.
- **Professional Features** - Multi-track timeline, keyframe animations, color grading, audio effects, and more.
- **GPU Accelerated** - WebGPU and WebCodecs for smooth 4K editing and fast exports.
- **Free Forever** - MIT licensed, no subscriptions, no watermarks.
---
## Features
### Video Editing
- **Multi-track timeline** - Unlimited video, audio, image, text, and graphics tracks
- **Real-time preview** - Smooth playback with GPU acceleration
- **Precision editing** - Frame-accurate scrubbing, cut, trim, split, ripple delete
- **Transitions** - Crossfade, dip to black/white, wipe, slide effects
- **Video effects** - Brightness, contrast, saturation, blur, sharpen, glow, vignette, chroma key
- **Blend modes** - Multiply, screen, overlay, add, subtract, and more
- **Speed control** - 0.25x to 4x with audio pitch preservation
- **Crop & transform** - Position, scale, rotation with 3D perspective
### Graphics & Text
- **Professional text editor** - Rich styling, shadows, outlines, gradients
- **20+ text animations** - Typewriter, fade, slide, bounce, pop, elastic, glitch
- **Karaoke-style subtitles** - Word-by-word highlighting synced to audio
- **Shape tools** - Rectangle, circle, arrow, polygon, star with fill/stroke
- **SVG support** - Import SVGs with color tinting and animations
- **Stickers & emoji** - Built-in library
- **Background generator** - Solid colors, gradients, mesh gradients, patterns
- **Keyframe animations** - Animate any property over time with 20+ easing curves
### Audio
- **Multi-track mixing** - Unlimited audio tracks with real-time mixing
- **Waveform visualization** - Visual audio editing
- **Audio effects** - EQ, compressor, reverb, delay, chorus, flanger, distortion
- **Volume & panning** - Per-clip controls with fade in/out
- **Beat detection** - Auto-generate markers synced to music
- **Audio ducking** - Auto-reduce music when dialog plays
- **Noise reduction** - 3-pass noise removal (tonal, broadband, rumble)
### Color Grading
- **Color wheels** - Lift, gamma, gain controls
- **HSL adjustments** - Hue, saturation, lightness fine-tuning
- **Curves editor** - RGB and individual channel curves
- **LUT support** - Import and apply 3D LUTs
- **Built-in presets** - One-click color grading
### Export
- **MP4 (H.264/H.265)** - Universal compatibility
- **WebM (VP8/VP9/AV1)** - Web-optimized format
- **ProRes** - Professional intermediate format (Proxy, LT, Standard, HQ, 4444)
- **Quality presets** - 4K @ 60fps, 1080p, 720p, 480p
- **Custom settings** - Bitrate, frame rate, codec options, color depth
- **Hardware encoding** - WebCodecs for fast exports
- **AI upscaling** - Enhance resolution with WebGPU shaders
- **Audio export** - MP3, WAV, AAC, FLAC, OGG
- **Image sequences** - JPG, PNG, WebP frame export
- **Progress tracking** - Real-time progress with cancel support
### Professional Tools
- **Unlimited undo/redo** - Full history with recovery
- **Auto-save** - Never lose work (IndexedDB storage)
- **Keyboard shortcuts** - Professional workflow
- **Snap to grid** - Magnetic alignment
- **Track management** - Show/hide, lock/unlock, reorder
- **Subtitle support** - SRT import with customizable styling
- **Screen recording** - Record screen, camera, or both
- **Project sharing** - Export/import project files
### Performance
- **WebGPU rendering** - GPU-accelerated compositing
- **WebCodecs API** - Hardware video decoding/encoding
- **Frame caching** - LRU cache for smooth playback
- **Web Workers** - Background processing
- **4K support** - Edit and export in 4K resolution
---
## Quick Start
### Try Online
Visit **[openreel.video](https://openreel.video)** to start editing immediately.
### Run Locally
```bash
# Clone the repository
git clone https://github.com/Augani/openreel-video.git
cd openreel-video
# Install dependencies (requires Node.js 18+)
pnpm install
# Start development server
pnpm dev
# Open http://localhost:5173
```
### Build for Production
```bash
pnpm build
pnpm preview
```
---
## Browser Requirements
| Browser | Version | Status |
|---------|---------|--------|
| Chrome | 94+ | Full support |
| Edge | 94+ | Full support |
| Firefox | 130+ | Full support |
| Safari | 16.4+ | Full support |
All major browsers now support WebCodecs for hardware-accelerated video encoding/decoding.
**Recommended:**
- 8GB+ RAM
- Dedicated GPU for 4K editing
- Modern multi-core CPU
---
## Architecture
### Monorepo Structure
```
openreel/
├── apps/web/ # React frontend (~66k lines)
│ └── src/
│ ├── components/ # UI components
│ │ └── editor/ # Editor panels (Timeline, Preview, Inspector)
│ ├── stores/ # Zustand state management
│ ├── services/ # Auto-save, shortcuts, screen recording
│ └── bridges/ # Engine coordination
└── packages/core/ # Core engines (~59k lines)
└── src/
├── video/ # Video processing, WebGPU rendering
├── audio/ # Web Audio API, effects, beat detection
├── graphics/ # Canvas/THREE.js, shapes, SVG
├── text/ # Text rendering, animations
├── export/ # MP4/WebM encoding
└── storage/ # IndexedDB, serialization
```
### Key Technologies
- **React 18** + **TypeScript** - Type-safe UI
- **Zustand** - Lightweight state management
- **MediaBunny** - Video/audio processing
- **WebCodecs** - Hardware encoding/decoding
- **WebGPU** - GPU-accelerated rendering
- **Web Audio API** - Professional audio processing
- **THREE.js** - 3D transforms and effects
- **IndexedDB** - Local project storage
### Design Principles
- **Action-based editing** - Every edit is an undoable action
- **Immutable state** - Predictable updates with Zustand
- **Engine separation** - Video, audio, graphics engines are independent
- **Progressive enhancement** - Graceful fallbacks (WebGPU → Canvas2D)
---
## AI-Managed Development
OpenReel is an experiment in AI-assisted open source development. Claude AI helps manage:
- **Issue triage** - Reviews and responds to issues
- **Code implementation** - Writes features and fixes bugs
- **Code review** - Maintains quality standards
- **Documentation** - Keeps docs up to date
Human oversight from Augustus ensures strategic direction and final approval on major changes. All code is public, tested, and follows best practices.
**What this means for contributors:**
- Issues get reviewed quickly (usually within 24 hours)
- Bug fixes ship fast
- Clear, detailed responses to questions
- High code quality standards
---
## Contributing
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
**Ways to contribute:**
- Report bugs with reproduction steps
- Suggest features in Discussions
- Submit PRs for bugs or features
- Improve documentation
- Write tests
- Share effect presets
**Development workflow:**
```bash
# Fork and clone
git clone https://github.com/Augani/openreel-video.git
# Create feature branch
git checkout -b feat/your-feature
# Make changes, then test
pnpm typecheck
pnpm test
pnpm lint
# Commit with conventional commits
git commit -m "feat: add your feature"
# Push and open PR
git push origin feat/your-feature
```
---
## Roadmap
### Completed
- Multi-track timeline with drag-and-drop
- Real-time video preview with GPU acceleration
- Full editing suite (cut, trim, split, transitions)
- Text editor with 20+ animations
- Graphics (shapes, SVG, stickers, backgrounds)
- Audio mixing with effects and beat detection
- Color grading with LUT support
- Keyframe animation system
- Export to MP4/WebM (4K supported)
- Screen recording
- AI upscaling
- Undo/redo with auto-save
### In Progress
- Nested sequences (timeline in timeline)
- Motion tracking
- More export formats (ProRes, GIF)
- Plugin system
### Planned
- Adjustment layers
- Advanced masking
- Audio spectral editing
- Collaborative editing
- Mobile optimization
---
## License
MIT License - Use freely for personal and commercial projects.
See [LICENSE](LICENSE) for details.
---
## Acknowledgments
**Built with:**
- [MediaBunny](https://mediabunny.dev) - Media processing
- [React](https://react.dev) - UI framework
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
- [THREE.js](https://threejs.org) - 3D rendering
- [TailwindCSS](https://tailwindcss.com) - Styling
**Inspired by:**
- DaVinci Resolve - Professional tools done right
- CapCut - Accessible editing for everyone
- Figma - Browser-based professional software
---
## Support
- **GitHub Issues** - Bug reports and feature requests
- **GitHub Discussions** - Questions and community chat
- **Twitter/X** - [@python_xi](https://x.com/python_xi)
---
## $OPENREEL Token
CA: `B7wDnfrdtvdG7SCkRjSMJ6LkVwGWvdWrQ75iV8G9pump`
---
**Built with care by [@python_xi](https://x.com/python_xi) and AI working together.**
*Making professional video editing accessible to everyone. Forever free. Forever open source.*

View file

@ -0,0 +1 @@
Vendored from Augani/openreel-video @ 2026-05-18T01:29:08Z

View file

@ -0,0 +1,70 @@
import js from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
export default [
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.es2021,
...globals.node,
NodeJS: "readonly",
CanvasTextAlign: "readonly",
CanvasTextBaseline: "readonly",
CanvasLineCap: "readonly",
CanvasLineJoin: "readonly",
CanvasFillRule: "readonly",
GlobalCompositeOperation: "readonly",
ImageBitmap: "readonly",
OffscreenCanvas: "readonly",
OffscreenCanvasRenderingContext2D: "readonly",
React: "readonly",
JSX: "readonly",
},
},
plugins: {
"@typescript-eslint": tseslint,
"react-hooks": reactHooks,
},
rules: {
...tseslint.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
"no-console": ["warn", { allow: ["warn", "error"] }],
"prefer-const": "warn",
"no-unused-vars": "off",
"no-empty": "warn",
"no-case-declarations": "warn",
"react-hooks/rules-of-hooks": "warn",
"react-hooks/exhaustive-deps": "warn",
},
linterOptions: {
reportUnusedDisableDirectives: false,
},
},
{
ignores: [
"dist/**",
"node_modules/**",
"*.config.js",
"*.config.ts",
"vite.config.ts",
],
},
];

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#22c55e" />
<meta name="description" content="Professional browser-based graphic design editor - Create stunning visuals offline" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=DM+Sans:wght@400;500;700&family=Poppins:wght@300;400;500;600;700;800;900&family=Montserrat:wght@300;400;500;600;700;800;900&family=Playfair+Display:wght@400;500;600;700;800;900&family=Roboto:wght@300;400;500;700;900&family=Open+Sans:wght@300;400;600;700;800&family=Lato:wght@300;400;700;900&family=Oswald:wght@300;400;500;600;700&family=Bebas+Neue&family=Pacifico&family=Lobster&family=Dancing+Script:wght@400;700&family=Great+Vibes&display=swap" rel="stylesheet" />
<title>OpenReel Image - Professional Graphic Design Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>

View file

@ -0,0 +1,64 @@
{
"name": "@openreel/image",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"deploy": "wrangler pages deploy dist --project-name=openreel-image",
"deploy:preview": "wrangler pages deploy dist --project-name=openreel-image --branch=preview",
"test": "vitest",
"test:run": "vitest run",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist node_modules/.vite"
},
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"@openreel/image-core": "workspace:*",
"@openreel/ui": "workspace:*",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"lucide-react": "^0.555.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"zod": "^4.4.3",
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.0.0",
"jsdom": "^24.1.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"vite": "^5.3.1",
"vitest": "^1.6.0",
"wrangler": "^3.114.17"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#22c55e"/>
<rect x="20" y="20" width="60" height="60" rx="8" fill="white" fill-opacity="0.9"/>
<circle cx="35" cy="38" r="8" fill="#22c55e"/>
<path d="M20 65 L45 45 L60 55 L80 35 L80 72 A8 8 0 0 1 72 80 L28 80 A8 8 0 0 1 20 72 Z" fill="#22c55e" fill-opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

View file

@ -0,0 +1,19 @@
{
"name": "OpenReel Image",
"short_name": "OpenReel",
"description": "Professional browser-based graphic design editor",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#22c55e",
"orientation": "landscape",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"categories": ["graphics", "design", "productivity"]
}

View file

@ -0,0 +1,47 @@
const CACHE_NAME = 'openreel-image-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
if (url.origin !== location.origin) return;
event.respondWith(
caches.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request)
.then((response) => {
if (response.ok && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => cached);
return cached || fetchPromise;
})
);
});

View file

@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useUIStore } from './stores/ui-store';
import { WelcomeScreen } from './components/welcome/WelcomeScreen';
import { EditorInterface } from './components/editor/EditorInterface';
import { KeyboardShortcutsPanel } from './components/editor/KeyboardShortcutsPanel';
import { SettingsDialog } from './components/editor/SettingsDialog';
import { useKeyboardShortcuts } from './services/keyboard-service';
import { useAutoSave } from './hooks/useAutoSave';
export default function App() {
const { currentView, setCurrentView, showShortcutsPanel, toggleShortcutsPanel, showSettingsDialog, closeSettingsDialog } = useUIStore();
useKeyboardShortcuts();
useAutoSave();
useEffect(() => {
document.documentElement.classList.add('dark');
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && currentView === 'editor') {
setCurrentView('welcome');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentView, setCurrentView]);
return (
<div className="h-full w-full bg-background">
{currentView === 'welcome' && <WelcomeScreen />}
{currentView === 'editor' && <EditorInterface />}
<KeyboardShortcutsPanel isOpen={showShortcutsPanel} onClose={toggleShortcutsPanel} />
<SettingsDialog isOpen={showSettingsDialog} onClose={closeSettingsDialog} />
</div>
);
}

View file

@ -0,0 +1,168 @@
export interface BlackWhiteSettings {
reds: number;
yellows: number;
greens: number;
cyans: number;
blues: number;
magentas: number;
tint: {
enabled: boolean;
hue: number;
saturation: number;
};
}
export const DEFAULT_BLACK_WHITE: BlackWhiteSettings = {
reds: 40,
yellows: 60,
greens: 40,
cyans: 60,
blues: 20,
magentas: 80,
tint: {
enabled: false,
hue: 30,
saturation: 25,
},
};
export const BLACK_WHITE_PRESETS = {
default: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 },
highContrast: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 },
infrared: { reds: -70, yellows: 200, greens: -70, cyans: 200, blues: -20, magentas: -20 },
maximumWhite: { reds: 100, yellows: 100, greens: 100, cyans: 100, blues: 100, magentas: 100 },
maximumBlack: { reds: -200, yellows: -200, greens: -200, cyans: -200, blues: -200, magentas: -200 },
neutral: { reds: 33, yellows: 33, greens: 33, cyans: 33, blues: 33, magentas: 33 },
redFilter: { reds: 106, yellows: 52, greens: -10, cyans: -40, blues: -30, magentas: 94 },
yellowFilter: { reds: 34, yellows: 106, greens: 54, cyans: -26, blues: -50, magentas: 14 },
greenFilter: { reds: -44, yellows: 64, greens: 106, cyans: 60, blues: -30, magentas: -70 },
blueFilter: { reds: -30, yellows: -46, greens: -16, cyans: 30, blues: 106, magentas: 30 },
};
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
return { h, s, l };
}
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
if (s === 0) {
const gray = Math.round(l * 255);
return { r: gray, g: gray, b: gray };
}
const hue2rgb = (p: number, q: number, t: number): number => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
};
}
function getColorWeight(hue: number, targetHue: number, spread: number = 60): number {
let diff = Math.abs(hue - targetHue);
if (diff > 180) diff = 360 - diff;
if (diff >= spread) return 0;
return 1 - diff / spread;
}
export function applyBlackWhite(imageData: ImageData, settings: BlackWhiteSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const { h, s } = rgbToHsl(r, g, b);
const hue = h * 360;
let gray = (r + g + b) / 3;
if (s > 0.05) {
const redWeight = getColorWeight(hue, 0) + getColorWeight(hue, 360);
const yellowWeight = getColorWeight(hue, 60);
const greenWeight = getColorWeight(hue, 120);
const cyanWeight = getColorWeight(hue, 180);
const blueWeight = getColorWeight(hue, 240);
const magentaWeight = getColorWeight(hue, 300);
const totalWeight = redWeight + yellowWeight + greenWeight + cyanWeight + blueWeight + magentaWeight;
if (totalWeight > 0) {
const adjustment =
(redWeight * settings.reds +
yellowWeight * settings.yellows +
greenWeight * settings.greens +
cyanWeight * settings.cyans +
blueWeight * settings.blues +
magentaWeight * settings.magentas) / totalWeight;
gray = gray * (1 + (adjustment - 50) / 100 * s);
}
}
gray = Math.max(0, Math.min(255, gray));
let finalR = gray;
let finalG = gray;
let finalB = gray;
if (settings.tint.enabled) {
const tintH = settings.tint.hue / 360;
const tintS = settings.tint.saturation / 100;
const tintL = gray / 255;
const tinted = hslToRgb(tintH, tintS, tintL);
finalR = tinted.r;
finalG = tinted.g;
finalB = tinted.b;
}
resultData[i] = finalR;
resultData[i + 1] = finalG;
resultData[i + 2] = finalB;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,108 @@
export interface ChannelMixerSettings {
red: {
red: number;
green: number;
blue: number;
constant: number;
};
green: {
red: number;
green: number;
blue: number;
constant: number;
};
blue: {
red: number;
green: number;
blue: number;
constant: number;
};
monochrome: boolean;
monoRed: number;
monoGreen: number;
monoBlue: number;
monoConstant: number;
}
export const DEFAULT_CHANNEL_MIXER: ChannelMixerSettings = {
red: { red: 100, green: 0, blue: 0, constant: 0 },
green: { red: 0, green: 100, blue: 0, constant: 0 },
blue: { red: 0, green: 0, blue: 100, constant: 0 },
monochrome: false,
monoRed: 40,
monoGreen: 40,
monoBlue: 20,
monoConstant: 0,
};
export const CHANNEL_MIXER_PRESETS = {
default: {
red: { red: 100, green: 0, blue: 0, constant: 0 },
green: { red: 0, green: 100, blue: 0, constant: 0 },
blue: { red: 0, green: 0, blue: 100, constant: 0 },
},
swapRedBlue: {
red: { red: 0, green: 0, blue: 100, constant: 0 },
green: { red: 0, green: 100, blue: 0, constant: 0 },
blue: { red: 100, green: 0, blue: 0, constant: 0 },
},
sepia: {
red: { red: 100, green: 50, blue: 0, constant: 0 },
green: { red: 60, green: 60, blue: 0, constant: 0 },
blue: { red: 30, green: 30, blue: 30, constant: 0 },
},
cyberPunk: {
red: { red: 100, green: 0, blue: 50, constant: 0 },
green: { red: 0, green: 100, blue: 50, constant: 0 },
blue: { red: 50, green: 0, blue: 100, constant: 0 },
},
};
export function applyChannelMixer(imageData: ImageData, settings: ChannelMixerSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
let newR: number, newG: number, newB: number;
if (settings.monochrome) {
const gray =
r * (settings.monoRed / 100) +
g * (settings.monoGreen / 100) +
b * (settings.monoBlue / 100) +
settings.monoConstant * 2.55;
newR = newG = newB = Math.max(0, Math.min(255, gray));
} else {
newR =
r * (settings.red.red / 100) +
g * (settings.red.green / 100) +
b * (settings.red.blue / 100) +
settings.red.constant * 2.55;
newG =
r * (settings.green.red / 100) +
g * (settings.green.green / 100) +
b * (settings.green.blue / 100) +
settings.green.constant * 2.55;
newB =
r * (settings.blue.red / 100) +
g * (settings.blue.green / 100) +
b * (settings.blue.blue / 100) +
settings.blue.constant * 2.55;
}
resultData[i] = Math.max(0, Math.min(255, newR));
resultData[i + 1] = Math.max(0, Math.min(255, newG));
resultData[i + 2] = Math.max(0, Math.min(255, newB));
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,111 @@
export interface ColorBalanceSettings {
shadows: {
cyanRed: number;
magentaGreen: number;
yellowBlue: number;
};
midtones: {
cyanRed: number;
magentaGreen: number;
yellowBlue: number;
};
highlights: {
cyanRed: number;
magentaGreen: number;
yellowBlue: number;
};
preserveLuminosity: boolean;
}
export const DEFAULT_COLOR_BALANCE: ColorBalanceSettings = {
shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
preserveLuminosity: true,
};
function getLuminance(r: number, g: number, b: number): number {
return r * 0.299 + g * 0.587 + b * 0.114;
}
function getToneWeight(luminance: number, tone: 'shadows' | 'midtones' | 'highlights'): number {
const normalized = luminance / 255;
switch (tone) {
case 'shadows':
if (normalized <= 0.25) return 1;
if (normalized <= 0.5) return 1 - (normalized - 0.25) / 0.25;
return 0;
case 'highlights':
if (normalized >= 0.75) return 1;
if (normalized >= 0.5) return (normalized - 0.5) / 0.25;
return 0;
case 'midtones':
if (normalized >= 0.25 && normalized <= 0.75) {
const distFromCenter = Math.abs(normalized - 0.5);
return 1 - distFromCenter / 0.25;
}
return 0;
}
}
export function applyColorBalance(imageData: ImageData, settings: ColorBalanceSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
for (let i = 0; i < data.length; i += 4) {
let r = data[i];
let g = data[i + 1];
let b = data[i + 2];
const a = data[i + 3];
const luminance = getLuminance(r, g, b);
const shadowWeight = getToneWeight(luminance, 'shadows');
const midtoneWeight = getToneWeight(luminance, 'midtones');
const highlightWeight = getToneWeight(luminance, 'highlights');
let rShift = 0, gShift = 0, bShift = 0;
if (shadowWeight > 0) {
rShift += settings.shadows.cyanRed * shadowWeight;
gShift += settings.shadows.magentaGreen * shadowWeight;
bShift += settings.shadows.yellowBlue * shadowWeight;
}
if (midtoneWeight > 0) {
rShift += settings.midtones.cyanRed * midtoneWeight;
gShift += settings.midtones.magentaGreen * midtoneWeight;
bShift += settings.midtones.yellowBlue * midtoneWeight;
}
if (highlightWeight > 0) {
rShift += settings.highlights.cyanRed * highlightWeight;
gShift += settings.highlights.magentaGreen * highlightWeight;
bShift += settings.highlights.yellowBlue * highlightWeight;
}
r = Math.max(0, Math.min(255, r + rShift));
g = Math.max(0, Math.min(255, g + gShift));
b = Math.max(0, Math.min(255, b + bShift));
if (settings.preserveLuminosity) {
const newLuminance = getLuminance(r, g, b);
if (newLuminance > 0) {
const ratio = luminance / newLuminance;
r = Math.max(0, Math.min(255, r * ratio));
g = Math.max(0, Math.min(255, g * ratio));
b = Math.max(0, Math.min(255, b * ratio));
}
}
resultData[i] = r;
resultData[i + 1] = g;
resultData[i + 2] = b;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,176 @@
export interface ColorLookupSettings {
lutData: Float32Array | null;
lutSize: number;
strength: number;
}
export const DEFAULT_COLOR_LOOKUP: ColorLookupSettings = {
lutData: null,
lutSize: 0,
strength: 100,
};
export function parseCubeLUT(content: string): { data: Float32Array; size: number } | null {
const lines = content.split('\n');
let size = 0;
const data: number[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('#') || trimmed === '') continue;
if (trimmed.startsWith('LUT_3D_SIZE')) {
const match = trimmed.match(/LUT_3D_SIZE\s+(\d+)/);
if (match) {
size = parseInt(match[1], 10);
}
continue;
}
if (trimmed.startsWith('TITLE') || trimmed.startsWith('DOMAIN_')) continue;
const values = trimmed.split(/\s+/).map(parseFloat);
if (values.length === 3 && values.every((v) => !isNaN(v))) {
data.push(...values);
}
}
if (size === 0 || data.length !== size * size * size * 3) {
return null;
}
return { data: new Float32Array(data), size };
}
export function parse3dlLUT(content: string): { data: Float32Array; size: number } | null {
const lines = content.split('\n');
const data: number[] = [];
let size = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) continue;
const values = trimmed.split(/\s+/).map(parseFloat);
if (values.length === 1 && size === 0) {
size = Math.round(Math.cbrt(values[0]));
continue;
}
if (values.length === 3 && values.every((v) => !isNaN(v))) {
data.push(values[0] / 4095, values[1] / 4095, values[2] / 4095);
}
}
if (size === 0) {
size = Math.round(Math.cbrt(data.length / 3));
}
if (size === 0 || data.length !== size * size * size * 3) {
return null;
}
return { data: new Float32Array(data), size };
}
function trilinearInterpolate(
lutData: Float32Array,
size: number,
r: number,
g: number,
b: number
): { r: number; g: number; b: number } {
const rScaled = r * (size - 1);
const gScaled = g * (size - 1);
const bScaled = b * (size - 1);
const r0 = Math.floor(rScaled);
const g0 = Math.floor(gScaled);
const b0 = Math.floor(bScaled);
const r1 = Math.min(r0 + 1, size - 1);
const g1 = Math.min(g0 + 1, size - 1);
const b1 = Math.min(b0 + 1, size - 1);
const rFrac = rScaled - r0;
const gFrac = gScaled - g0;
const bFrac = bScaled - b0;
const getIndex = (ri: number, gi: number, bi: number) => (bi * size * size + gi * size + ri) * 3;
const c000 = getIndex(r0, g0, b0);
const c100 = getIndex(r1, g0, b0);
const c010 = getIndex(r0, g1, b0);
const c110 = getIndex(r1, g1, b0);
const c001 = getIndex(r0, g0, b1);
const c101 = getIndex(r1, g0, b1);
const c011 = getIndex(r0, g1, b1);
const c111 = getIndex(r1, g1, b1);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const interpolate = (channel: number) => {
const c00 = lerp(lutData[c000 + channel], lutData[c100 + channel], rFrac);
const c01 = lerp(lutData[c001 + channel], lutData[c101 + channel], rFrac);
const c10 = lerp(lutData[c010 + channel], lutData[c110 + channel], rFrac);
const c11 = lerp(lutData[c011 + channel], lutData[c111 + channel], rFrac);
const c0 = lerp(c00, c10, gFrac);
const c1 = lerp(c01, c11, gFrac);
return lerp(c0, c1, bFrac);
};
return {
r: interpolate(0),
g: interpolate(1),
b: interpolate(2),
};
}
export function applyColorLookup(imageData: ImageData, settings: ColorLookupSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
if (!settings.lutData || settings.lutSize === 0) {
resultData.set(data);
return new ImageData(resultData, width, height);
}
const strength = settings.strength / 100;
for (let i = 0; i < data.length; i += 4) {
const r = data[i] / 255;
const g = data[i + 1] / 255;
const b = data[i + 2] / 255;
const a = data[i + 3];
const lutColor = trilinearInterpolate(settings.lutData, settings.lutSize, r, g, b);
resultData[i] = Math.max(0, Math.min(255, (r + (lutColor.r - r) * strength) * 255));
resultData[i + 1] = Math.max(0, Math.min(255, (g + (lutColor.g - g) * strength) * 255));
resultData[i + 2] = Math.max(0, Math.min(255, (b + (lutColor.b - b) * strength) * 255));
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}
export function createIdentityLUT(size: number): Float32Array {
const data = new Float32Array(size * size * size * 3);
for (let b = 0; b < size; b++) {
for (let g = 0; g < size; g++) {
for (let r = 0; r < size; r++) {
const idx = (b * size * size + g * size + r) * 3;
data[idx] = r / (size - 1);
data[idx + 1] = g / (size - 1);
data[idx + 2] = b / (size - 1);
}
}
}
return data;
}

View file

@ -0,0 +1,164 @@
export interface GradientStop {
position: number;
color: string;
}
export interface GradientMapSettings {
stops: GradientStop[];
dither: boolean;
reverse: boolean;
}
export const DEFAULT_GRADIENT_MAP: GradientMapSettings = {
stops: [
{ position: 0, color: '#000000' },
{ position: 100, color: '#ffffff' },
],
dither: false,
reverse: false,
};
export const GRADIENT_MAP_PRESETS = {
blackWhite: [
{ position: 0, color: '#000000' },
{ position: 100, color: '#ffffff' },
],
sepiaTone: [
{ position: 0, color: '#1a0f00' },
{ position: 50, color: '#8b6914' },
{ position: 100, color: '#ffe7b3' },
],
duotoneBlueOrange: [
{ position: 0, color: '#001f4d' },
{ position: 100, color: '#ff8c00' },
],
duotonePurpleTeal: [
{ position: 0, color: '#2d1b4e' },
{ position: 100, color: '#00d4aa' },
],
sunset: [
{ position: 0, color: '#1a0533' },
{ position: 33, color: '#6b1839' },
{ position: 66, color: '#d44d1b' },
{ position: 100, color: '#ffd700' },
],
coolBlue: [
{ position: 0, color: '#000033' },
{ position: 50, color: '#0066cc' },
{ position: 100, color: '#99ccff' },
],
warmRed: [
{ position: 0, color: '#1a0000' },
{ position: 50, color: '#cc3300' },
{ position: 100, color: '#ffcc99' },
],
greenForest: [
{ position: 0, color: '#001a00' },
{ position: 50, color: '#336600' },
{ position: 100, color: '#99cc66' },
],
infrared: [
{ position: 0, color: '#000000' },
{ position: 25, color: '#330066' },
{ position: 50, color: '#ff0066' },
{ position: 75, color: '#ffcc00' },
{ position: 100, color: '#ffffff' },
],
thermal: [
{ position: 0, color: '#000033' },
{ position: 25, color: '#6600cc' },
{ position: 50, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#ffffff' },
],
};
function parseColor(color: string): { r: number; g: number; b: number } {
const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (match) {
return {
r: parseInt(match[1], 16),
g: parseInt(match[2], 16),
b: parseInt(match[3], 16),
};
}
return { r: 0, g: 0, b: 0 };
}
function interpolateGradient(
stops: GradientStop[],
position: number
): { r: number; g: number; b: number } {
if (stops.length === 0) return { r: 0, g: 0, b: 0 };
if (stops.length === 1) return parseColor(stops[0].color);
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
if (position <= sortedStops[0].position) {
return parseColor(sortedStops[0].color);
}
if (position >= sortedStops[sortedStops.length - 1].position) {
return parseColor(sortedStops[sortedStops.length - 1].color);
}
for (let i = 0; i < sortedStops.length - 1; i++) {
const stop1 = sortedStops[i];
const stop2 = sortedStops[i + 1];
if (position >= stop1.position && position <= stop2.position) {
const t = (position - stop1.position) / (stop2.position - stop1.position);
const c1 = parseColor(stop1.color);
const c2 = parseColor(stop2.color);
return {
r: Math.round(c1.r + (c2.r - c1.r) * t),
g: Math.round(c1.g + (c2.g - c1.g) * t),
b: Math.round(c1.b + (c2.b - c1.b) * t),
};
}
}
return parseColor(sortedStops[sortedStops.length - 1].color);
}
function getLuminance(r: number, g: number, b: number): number {
return (r * 0.299 + g * 0.587 + b * 0.114) / 255;
}
export function applyGradientMap(imageData: ImageData, settings: GradientMapSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const lookupTable: Array<{ r: number; g: number; b: number }> = [];
for (let i = 0; i < 256; i++) {
let position = (i / 255) * 100;
if (settings.reverse) {
position = 100 - position;
}
lookupTable[i] = interpolateGradient(settings.stops, position);
}
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
let luminance = getLuminance(r, g, b);
if (settings.dither) {
const noise = (Math.random() - 0.5) * (1 / 255);
luminance = Math.max(0, Math.min(1, luminance + noise));
}
const idx = Math.round(luminance * 255);
const mappedColor = lookupTable[idx];
resultData[i] = mappedColor.r;
resultData[i + 1] = mappedColor.g;
resultData[i + 2] = mappedColor.b;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,305 @@
export interface HistogramData {
red: Uint32Array;
green: Uint32Array;
blue: Uint32Array;
luminosity: Uint32Array;
}
export interface HistogramStatistics {
mean: number;
stdDev: number;
median: number;
min: number;
max: number;
pixelCount: number;
shadowsClipped: number;
highlightsClipped: number;
}
export interface HistogramResult {
data: HistogramData;
statistics: {
red: HistogramStatistics;
green: HistogramStatistics;
blue: HistogramStatistics;
luminosity: HistogramStatistics;
};
}
export interface ColorInfo {
rgb: { r: number; g: number; b: number };
hsb: { h: number; s: number; b: number };
hsl: { h: number; s: number; l: number };
lab: { l: number; a: number; b: number };
cmyk: { c: number; m: number; y: number; k: number };
hex: string;
}
function calculateStatistics(histogram: Uint32Array, totalPixels: number): HistogramStatistics {
let sum = 0;
let min = 255;
let max = 0;
let pixelCount = 0;
for (let i = 0; i < 256; i++) {
const count = histogram[i];
if (count > 0) {
sum += i * count;
pixelCount += count;
if (i < min) min = i;
if (i > max) max = i;
}
}
const mean = pixelCount > 0 ? sum / pixelCount : 0;
let varianceSum = 0;
for (let i = 0; i < 256; i++) {
const count = histogram[i];
if (count > 0) {
varianceSum += count * Math.pow(i - mean, 2);
}
}
const stdDev = pixelCount > 0 ? Math.sqrt(varianceSum / pixelCount) : 0;
let medianCount = 0;
let median = 0;
const halfCount = pixelCount / 2;
for (let i = 0; i < 256; i++) {
medianCount += histogram[i];
if (medianCount >= halfCount) {
median = i;
break;
}
}
const shadowsClipped = (histogram[0] / totalPixels) * 100;
const highlightsClipped = (histogram[255] / totalPixels) * 100;
return {
mean,
stdDev,
median,
min: pixelCount > 0 ? min : 0,
max: pixelCount > 0 ? max : 0,
pixelCount,
shadowsClipped,
highlightsClipped,
};
}
export function calculateHistogram(imageData: ImageData): HistogramResult {
const { data } = imageData;
const histogramData: HistogramData = {
red: new Uint32Array(256),
green: new Uint32Array(256),
blue: new Uint32Array(256),
luminosity: new Uint32Array(256),
};
const totalPixels = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
histogramData.red[r]++;
histogramData.green[g]++;
histogramData.blue[b]++;
const luminosity = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
histogramData.luminosity[luminosity]++;
}
return {
data: histogramData,
statistics: {
red: calculateStatistics(histogramData.red, totalPixels),
green: calculateStatistics(histogramData.green, totalPixels),
blue: calculateStatistics(histogramData.blue, totalPixels),
luminosity: calculateStatistics(histogramData.luminosity, totalPixels),
},
};
}
export function getColorInfo(r: number, g: number, b: number): ColorInfo {
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
const delta = max - min;
let h = 0;
if (delta !== 0) {
if (max === rNorm) {
h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) / 6;
} else if (max === gNorm) {
h = ((bNorm - rNorm) / delta + 2) / 6;
} else {
h = ((rNorm - gNorm) / delta + 4) / 6;
}
}
const l = (max + min) / 2;
const sHsl = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
const sBrightness = max === 0 ? 0 : delta / max;
const k = 1 - max;
const c = max === 0 ? 0 : (1 - rNorm - k) / (1 - k);
const m = max === 0 ? 0 : (1 - gNorm - k) / (1 - k);
const y = max === 0 ? 0 : (1 - bNorm - k) / (1 - k);
const xyzR = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
const xyzG = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
const xyzB = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
const x = (xyzR * 0.4124564 + xyzG * 0.3575761 + xyzB * 0.1804375) / 0.95047;
const yVal = xyzR * 0.2126729 + xyzG * 0.7151522 + xyzB * 0.0721750;
const z = (xyzR * 0.0193339 + xyzG * 0.1191920 + xyzB * 0.9503041) / 1.08883;
const f = (t: number) => t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116;
const labL = 116 * f(yVal) - 16;
const labA = 500 * (f(x) - f(yVal));
const labB = 200 * (f(yVal) - f(z));
const hex = '#' +
r.toString(16).padStart(2, '0') +
g.toString(16).padStart(2, '0') +
b.toString(16).padStart(2, '0');
return {
rgb: { r, g, b },
hsb: {
h: Math.round(h * 360),
s: Math.round(sBrightness * 100),
b: Math.round(max * 100),
},
hsl: {
h: Math.round(h * 360),
s: Math.round(sHsl * 100),
l: Math.round(l * 100),
},
lab: {
l: Math.round(labL),
a: Math.round(labA),
b: Math.round(labB),
},
cmyk: {
c: Math.round(c * 100),
m: Math.round(m * 100),
y: Math.round(y * 100),
k: Math.round(k * 100),
},
hex,
};
}
export function renderHistogram(
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
histogram: Uint32Array,
color: string,
width: number,
height: number,
logarithmic: boolean = false
): void {
const maxValue = Math.max(...histogram);
if (maxValue === 0) return;
ctx.fillStyle = color;
ctx.globalAlpha = 0.7;
const barWidth = width / 256;
for (let i = 0; i < 256; i++) {
let normalizedValue: number;
if (logarithmic && histogram[i] > 0) {
normalizedValue = Math.log10(histogram[i] + 1) / Math.log10(maxValue + 1);
} else {
normalizedValue = histogram[i] / maxValue;
}
const barHeight = normalizedValue * height;
ctx.fillRect(i * barWidth, height - barHeight, barWidth, barHeight);
}
ctx.globalAlpha = 1;
}
export function autoLevels(imageData: ImageData, clipPercent: number = 0.1): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const histogram = calculateHistogram(imageData);
const totalPixels = data.length / 4;
const clipPixels = Math.round(totalPixels * (clipPercent / 100));
const findClipPoint = (hist: Uint32Array, fromStart: boolean): number => {
let count = 0;
if (fromStart) {
for (let i = 0; i < 256; i++) {
count += hist[i];
if (count > clipPixels) return i;
}
return 0;
} else {
for (let i = 255; i >= 0; i--) {
count += hist[i];
if (count > clipPixels) return i;
}
return 255;
}
};
const channels = ['red', 'green', 'blue'] as const;
const adjustments = channels.map((channel) => {
const hist = histogram.data[channel];
const inputBlack = findClipPoint(hist, true);
const inputWhite = findClipPoint(hist, false);
return { inputBlack, inputWhite };
});
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const { inputBlack, inputWhite } = adjustments[c];
const range = inputWhite - inputBlack || 1;
const value = data[i + c];
const adjusted = ((value - inputBlack) / range) * 255;
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}
export function autoContrast(imageData: ImageData): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
let minLum = 255;
let maxLum = 0;
for (let i = 0; i < data.length; i += 4) {
const lum = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
if (lum < minLum) minLum = lum;
if (lum > maxLum) maxLum = lum;
}
const range = maxLum - minLum || 1;
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const adjusted = ((data[i + c] - minLum) / range) * 255;
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,117 @@
export type PhotoFilterPreset =
| 'warming-85'
| 'warming-81'
| 'warming-lba'
| 'cooling-80'
| 'cooling-82'
| 'cooling-lbb'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'cyan'
| 'blue'
| 'violet'
| 'magenta'
| 'sepia'
| 'deep-red'
| 'deep-blue'
| 'deep-emerald'
| 'deep-yellow'
| 'underwater'
| 'custom';
export interface PhotoFilterSettings {
filter: PhotoFilterPreset;
color: string;
density: number;
preserveLuminosity: boolean;
}
export const DEFAULT_PHOTO_FILTER: PhotoFilterSettings = {
filter: 'warming-85',
color: '#ec8a00',
density: 25,
preserveLuminosity: true,
};
export const PHOTO_FILTER_COLORS: Record<PhotoFilterPreset, string> = {
'warming-85': '#ec8a00',
'warming-81': '#ebb113',
'warming-lba': '#fa9600',
'cooling-80': '#006dff',
'cooling-82': '#00b5ff',
'cooling-lbb': '#005fcc',
red: '#ea1a1a',
orange: '#f28e00',
yellow: '#f9d71c',
green: '#1ab800',
cyan: '#00e5e5',
blue: '#0000ff',
violet: '#8000ff',
magenta: '#ea00ea',
sepia: '#ac7a33',
'deep-red': '#a10000',
'deep-blue': '#000066',
'deep-emerald': '#003d00',
'deep-yellow': '#998c00',
underwater: '#00c2b0',
custom: '#ffffff',
};
function parseColor(color: string): { r: number; g: number; b: number } {
const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (match) {
return {
r: parseInt(match[1], 16),
g: parseInt(match[2], 16),
b: parseInt(match[3], 16),
};
}
return { r: 255, g: 255, b: 255 };
}
function getLuminance(r: number, g: number, b: number): number {
return r * 0.299 + g * 0.587 + b * 0.114;
}
export function applyPhotoFilter(imageData: ImageData, settings: PhotoFilterSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const filterColor = settings.filter === 'custom'
? parseColor(settings.color)
: parseColor(PHOTO_FILTER_COLORS[settings.filter]);
const density = settings.density / 100;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const originalLuminance = getLuminance(r, g, b);
let newR = r + (filterColor.r - r) * density;
let newG = g + (filterColor.g - g) * density;
let newB = b + (filterColor.b - b) * density;
if (settings.preserveLuminosity) {
const newLuminance = getLuminance(newR, newG, newB);
if (newLuminance > 0) {
const ratio = originalLuminance / newLuminance;
newR *= ratio;
newG *= ratio;
newB *= ratio;
}
}
resultData[i] = Math.max(0, Math.min(255, newR));
resultData[i + 1] = Math.max(0, Math.min(255, newG));
resultData[i + 2] = Math.max(0, Math.min(255, newB));
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,108 @@
export interface PosterizeSettings {
levels: number;
}
export interface ThresholdSettings {
level: number;
}
export const DEFAULT_POSTERIZE: PosterizeSettings = {
levels: 4,
};
export const DEFAULT_THRESHOLD: ThresholdSettings = {
level: 128,
};
export function applyPosterize(imageData: ImageData, settings: PosterizeSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const levels = Math.max(2, Math.min(255, Math.round(settings.levels)));
const step = 255 / (levels - 1);
const divisor = 256 / levels;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
resultData[i] = Math.round(Math.floor(r / divisor) * step);
resultData[i + 1] = Math.round(Math.floor(g / divisor) * step);
resultData[i + 2] = Math.round(Math.floor(b / divisor) * step);
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}
export function applyThreshold(imageData: ImageData, settings: ThresholdSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const level = Math.max(0, Math.min(255, settings.level));
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const luminance = r * 0.299 + g * 0.587 + b * 0.114;
const value = luminance >= level ? 255 : 0;
resultData[i] = value;
resultData[i + 1] = value;
resultData[i + 2] = value;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}
export function applyAdaptiveThreshold(
imageData: ImageData,
blockSize: number = 11,
constant: number = 2
): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const grayData = new Uint8Array(width * height);
for (let i = 0; i < data.length; i += 4) {
const idx = i / 4;
grayData[idx] = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
}
const halfBlock = Math.floor(blockSize / 2);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let sum = 0;
let count = 0;
for (let by = -halfBlock; by <= halfBlock; by++) {
for (let bx = -halfBlock; bx <= halfBlock; bx++) {
const nx = Math.min(Math.max(x + bx, 0), width - 1);
const ny = Math.min(Math.max(y + by, 0), height - 1);
sum += grayData[ny * width + nx];
count++;
}
}
const mean = sum / count;
const threshold = mean - constant;
const pixelIdx = y * width + x;
const value = grayData[pixelIdx] > threshold ? 255 : 0;
const i = pixelIdx * 4;
resultData[i] = value;
resultData[i + 1] = value;
resultData[i + 2] = value;
resultData[i + 3] = data[i + 3];
}
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,225 @@
export type SelectiveColorRange =
| 'reds'
| 'yellows'
| 'greens'
| 'cyans'
| 'blues'
| 'magentas'
| 'whites'
| 'neutrals'
| 'blacks';
export interface SelectiveColorAdjustment {
cyan: number;
magenta: number;
yellow: number;
black: number;
}
export interface SelectiveColorSettings {
reds: SelectiveColorAdjustment;
yellows: SelectiveColorAdjustment;
greens: SelectiveColorAdjustment;
cyans: SelectiveColorAdjustment;
blues: SelectiveColorAdjustment;
magentas: SelectiveColorAdjustment;
whites: SelectiveColorAdjustment;
neutrals: SelectiveColorAdjustment;
blacks: SelectiveColorAdjustment;
method: 'relative' | 'absolute';
}
const DEFAULT_ADJUSTMENT: SelectiveColorAdjustment = {
cyan: 0,
magenta: 0,
yellow: 0,
black: 0,
};
export const DEFAULT_SELECTIVE_COLOR: SelectiveColorSettings = {
reds: { ...DEFAULT_ADJUSTMENT },
yellows: { ...DEFAULT_ADJUSTMENT },
greens: { ...DEFAULT_ADJUSTMENT },
cyans: { ...DEFAULT_ADJUSTMENT },
blues: { ...DEFAULT_ADJUSTMENT },
magentas: { ...DEFAULT_ADJUSTMENT },
whites: { ...DEFAULT_ADJUSTMENT },
neutrals: { ...DEFAULT_ADJUSTMENT },
blacks: { ...DEFAULT_ADJUSTMENT },
method: 'relative',
};
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
return { h, s, l };
}
function getColorRangeWeight(r: number, g: number, b: number, range: SelectiveColorRange): number {
const { h, s, l } = rgbToHsl(r, g, b);
const hue = h * 360;
switch (range) {
case 'reds':
if (s < 0.1) return 0;
if ((hue >= 345 || hue <= 15)) return s;
if (hue > 15 && hue <= 45) return s * (1 - (hue - 15) / 30);
if (hue >= 315 && hue < 345) return s * ((hue - 315) / 30);
return 0;
case 'yellows':
if (s < 0.1) return 0;
if (hue >= 45 && hue <= 75) return s;
if (hue > 15 && hue < 45) return s * ((hue - 15) / 30);
if (hue > 75 && hue <= 105) return s * (1 - (hue - 75) / 30);
return 0;
case 'greens':
if (s < 0.1) return 0;
if (hue >= 105 && hue <= 135) return s;
if (hue > 75 && hue < 105) return s * ((hue - 75) / 30);
if (hue > 135 && hue <= 165) return s * (1 - (hue - 135) / 30);
return 0;
case 'cyans':
if (s < 0.1) return 0;
if (hue >= 165 && hue <= 195) return s;
if (hue > 135 && hue < 165) return s * ((hue - 135) / 30);
if (hue > 195 && hue <= 225) return s * (1 - (hue - 195) / 30);
return 0;
case 'blues':
if (s < 0.1) return 0;
if (hue >= 225 && hue <= 255) return s;
if (hue > 195 && hue < 225) return s * ((hue - 195) / 30);
if (hue > 255 && hue <= 285) return s * (1 - (hue - 255) / 30);
return 0;
case 'magentas':
if (s < 0.1) return 0;
if (hue >= 285 && hue <= 315) return s;
if (hue > 255 && hue < 285) return s * ((hue - 255) / 30);
if (hue > 315 && hue <= 345) return s * (1 - (hue - 315) / 30);
return 0;
case 'whites':
if (l >= 0.8) return (l - 0.8) / 0.2;
return 0;
case 'blacks':
if (l <= 0.2) return (0.2 - l) / 0.2;
return 0;
case 'neutrals':
if (s < 0.2 && l > 0.2 && l < 0.8) {
return (0.2 - s) / 0.2 * Math.min((l - 0.2) / 0.3, (0.8 - l) / 0.3, 1);
}
return 0;
}
}
function rgbToCmyk(r: number, g: number, b: number): { c: number; m: number; y: number; k: number } {
r /= 255;
g /= 255;
b /= 255;
const k = 1 - Math.max(r, g, b);
if (k === 1) {
return { c: 0, m: 0, y: 0, k: 1 };
}
const c = (1 - r - k) / (1 - k);
const m = (1 - g - k) / (1 - k);
const y = (1 - b - k) / (1 - k);
return { c, m, y, k };
}
function cmykToRgb(c: number, m: number, y: number, k: number): { r: number; g: number; b: number } {
const r = 255 * (1 - c) * (1 - k);
const g = 255 * (1 - m) * (1 - k);
const b = 255 * (1 - y) * (1 - k);
return {
r: Math.max(0, Math.min(255, Math.round(r))),
g: Math.max(0, Math.min(255, Math.round(g))),
b: Math.max(0, Math.min(255, Math.round(b))),
};
}
export function applySelectiveColor(imageData: ImageData, settings: SelectiveColorSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const ranges: SelectiveColorRange[] = [
'reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'whites', 'neutrals', 'blacks'
];
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
let { c, m, y, k } = rgbToCmyk(r, g, b);
for (const range of ranges) {
const weight = getColorRangeWeight(r, g, b, range);
if (weight <= 0) continue;
const adj = settings[range];
if (settings.method === 'relative') {
c = c + (adj.cyan / 100) * c * weight;
m = m + (adj.magenta / 100) * m * weight;
y = y + (adj.yellow / 100) * y * weight;
k = k + (adj.black / 100) * k * weight;
} else {
c = c + (adj.cyan / 100) * weight;
m = m + (adj.magenta / 100) * weight;
y = y + (adj.yellow / 100) * weight;
k = k + (adj.black / 100) * weight;
}
}
c = Math.max(0, Math.min(1, c));
m = Math.max(0, Math.min(1, m));
y = Math.max(0, Math.min(1, y));
k = Math.max(0, Math.min(1, k));
const rgb = cmykToRgb(c, m, y, k);
resultData[i] = rgb.r;
resultData[i + 1] = rgb.g;
resultData[i + 2] = rgb.b;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import { parseProject } from './services/project-schema';
import { migrateProject, CURRENT_VERSION } from './services/project-migration';
// ── App smoke tests ──────────────────────────────────────────────────────────
//
// These tests exercise the integration seam between the project schema,
// migration utilities, and the store to confirm the whole pipeline is wired up
// and importing correctly.
describe('OpenReel Image baseline smoke tests', () => {
// Schema is importable.
it('project schema module is importable', () => {
expect(typeof parseProject).toBe('function');
});
// Migration is importable and exposes the current version constant.
it('migration module exposes CURRENT_VERSION', () => {
expect(typeof CURRENT_VERSION).toBe('number');
expect(CURRENT_VERSION).toBeGreaterThanOrEqual(1);
});
// A minimal valid project document passes schema validation.
it('validates a minimal valid project', () => {
const baseLayer = {
id: 'l1',
name: 'Layer',
type: 'text' as const,
visible: true,
locked: false,
transform: {
x: 0, y: 0, width: 200, height: 50, rotation: 0,
scaleX: 1, scaleY: 1, skewX: 0, skewY: 0, opacity: 1,
},
blendMode: { mode: 'normal' as const },
shadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 0, offsetY: 4 },
innerShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 2, offsetY: 2 },
stroke: { enabled: false, color: '#000000', width: 1, style: 'solid' as const },
glow: { enabled: false, color: '#ffffff', blur: 20, intensity: 1 },
filters: {
brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0,
vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0,
blurType: 'gaussian' as const, blurAngle: 0, sharpen: 0, vignette: 0,
grain: 0, sepia: 0, invert: 0,
},
parentId: null,
flipHorizontal: false,
flipVertical: false,
mask: null,
clippingMask: false,
levels: {
enabled: false,
master: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
red: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
green: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
blue: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
},
curves: {
enabled: false,
master: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
red: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
green: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
blue: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
},
colorBalance: {
enabled: false,
shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
preserveLuminosity: true,
},
selectiveColor: {
enabled: false, method: 'relative' as const,
reds: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
yellows: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
greens: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
cyans: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
blues: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
magentas: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
whites: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
neutrals: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
blacks: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
},
blackWhite: {
enabled: false, reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20,
magentas: 80, tintEnabled: false, tintHue: 35, tintSaturation: 25,
},
photoFilter: {
enabled: false, filter: 'warming-85' as const, color: '#ec8a00',
density: 25, preserveLuminosity: true,
},
channelMixer: {
enabled: false, monochrome: false,
red: { red: 100, green: 0, blue: 0, constant: 0 },
green: { red: 0, green: 100, blue: 0, constant: 0 },
blue: { red: 0, green: 0, blue: 100, constant: 0 },
},
gradientMap: {
enabled: false,
stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }],
reverse: false, dither: false,
},
posterize: { enabled: false, levels: 4 },
threshold: { enabled: false, level: 128 },
content: 'Hello',
style: {
fontFamily: 'Inter', fontSize: 24, fontWeight: 400,
fontStyle: 'normal' as const, textDecoration: 'none' as const,
textAlign: 'left' as const, verticalAlign: 'top' as const,
lineHeight: 1.4, letterSpacing: 0, fillType: 'solid' as const,
color: '#ffffff', gradient: null, strokeColor: null, strokeWidth: 0,
backgroundColor: null, backgroundPadding: 8, backgroundRadius: 4,
textShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 4, offsetX: 0, offsetY: 2 },
},
autoSize: true,
};
const validProject = {
id: 'p1',
name: 'Smoke Test',
createdAt: Date.now(),
updatedAt: Date.now(),
version: 1,
artboards: [
{
id: 'ab1',
name: 'Artboard 1',
size: { width: 1080, height: 1080 },
background: { type: 'color', color: '#ffffff' },
layerIds: ['l1'],
position: { x: 0, y: 0 },
},
],
layers: { l1: baseLayer },
assets: {},
activeArtboardId: 'ab1',
};
const result = parseProject(validProject);
expect(result.success).toBe(true);
});
// An invalid document is rejected.
it('rejects an invalid project document', () => {
const result = parseProject({ id: 42, broken: true });
expect(result.success).toBe(false);
});
// Migration promotes a v0 document to v1.
it('migrates a v0 project to v1', () => {
const v0 = {
id: 'old',
name: 'Legacy',
createdAt: 0,
updatedAt: 0,
artboards: [{ id: 'ab-old', name: 'Page 1' }],
layers: {},
assets: {},
};
const migrated = migrateProject(v0 as Record<string, unknown>);
expect(migrated.version).toBe(1);
expect(migrated.activeArtboardId).toBe('ab-old');
});
// A project that already has version 1 is returned unchanged.
it('does not re-migrate a current-version project', () => {
const v1 = {
id: 'current',
name: 'New',
createdAt: 0,
updatedAt: 0,
version: 1,
artboards: [],
layers: {},
assets: {},
activeArtboardId: null,
};
const migrated = migrateProject(v1 as Record<string, unknown>);
expect(migrated.version).toBe(1);
});
});

View file

@ -0,0 +1,107 @@
import { useState, lazy, Suspense } from 'react';
import { Toolbar } from './toolbar/Toolbar';
import { LeftPanel } from './panels/LeftPanel';
import { Canvas } from './canvas/Canvas';
import { Inspector } from './inspector/Inspector';
import { LayerPanel } from './layers/LayerPanel';
import { HistoryPanel } from './panels/HistoryPanel';
import { GuidePanel } from './panels/GuidePanel';
import { PagesBar } from './pages/PagesBar';
import { useUIStore } from '../../stores/ui-store';
import { useProjectStore } from '../../stores/project-store';
import { Layers, History, Ruler } from 'lucide-react';
const ExportDialog = lazy(() => import('./ExportDialog').then(m => ({ default: m.ExportDialog })));
type BottomTab = 'layers' | 'history' | 'guides';
export function EditorInterface() {
const { isPanelCollapsed, isInspectorCollapsed, isExportDialogOpen, closeExportDialog } = useUIStore();
const { project } = useProjectStore();
const [bottomTab, setBottomTab] = useState<BottomTab>('layers');
if (!project) {
return (
<div className="h-full w-full flex items-center justify-center bg-background">
<p className="text-muted-foreground">No project loaded</p>
</div>
);
}
return (
<div className="h-full w-full flex flex-col bg-background overflow-hidden">
<Toolbar />
<div className="flex-1 flex overflow-hidden">
{!isPanelCollapsed && (
<div className="w-72 border-r border-border flex flex-col bg-card">
<LeftPanel />
</div>
)}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex overflow-hidden">
<Canvas />
</div>
<PagesBar />
</div>
{!isInspectorCollapsed && (
<div className="w-72 border-l border-border flex flex-col bg-card">
<div className="flex-1 overflow-y-auto">
<Inspector />
</div>
<div className="h-64 border-t border-border flex flex-col">
<div className="flex border-b border-border">
<button
onClick={() => setBottomTab('layers')}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
bottomTab === 'layers'
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<Layers size={14} />
Layers
</button>
<button
onClick={() => setBottomTab('guides')}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
bottomTab === 'guides'
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<Ruler size={14} />
Guides
</button>
<button
onClick={() => setBottomTab('history')}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
bottomTab === 'history'
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<History size={14} />
History
</button>
</div>
<div className="flex-1 overflow-hidden">
{bottomTab === 'layers' && <LayerPanel />}
{bottomTab === 'guides' && <GuidePanel />}
{bottomTab === 'history' && <HistoryPanel />}
</div>
</div>
</div>
)}
</div>
{isExportDialogOpen && (
<Suspense fallback={null}>
<ExportDialog open={isExportDialogOpen} onClose={closeExportDialog} />
</Suspense>
)}
</div>
);
}

View file

@ -0,0 +1,626 @@
import { useState, useMemo, useEffect } from 'react';
import { Download, FileImage, Loader2, Link2, Link2Off, Printer, Instagram, Youtube, Twitter, Linkedin, Facebook, Image } from 'lucide-react';
import { Dialog, DialogFooter } from '../ui/Dialog';
import { useProjectStore } from '../../stores/project-store';
import { useUIStore } from '../../stores/ui-store';
import {
exportProject,
downloadBlob,
getExportFilename,
type ExportFormat,
type ExportQuality,
type ExportOptions,
} from '../../services/export-service';
interface ExportDialogProps {
open: boolean;
onClose: () => void;
}
type FormatInfo = {
id: ExportFormat;
name: string;
description: string;
supportsTransparency: boolean;
supportsQuality: boolean;
};
const FORMATS: FormatInfo[] = [
{ id: 'png', name: 'PNG', description: 'Lossless, best for graphics', supportsTransparency: true, supportsQuality: false },
{ id: 'jpg', name: 'JPG', description: 'Smaller size, photos', supportsTransparency: false, supportsQuality: true },
{ id: 'webp', name: 'WebP', description: 'Modern, best compression', supportsTransparency: true, supportsQuality: true },
];
const QUALITY_PRESETS: { id: ExportQuality; name: string; value: number }[] = [
{ id: 'low', name: 'Low', value: 60 },
{ id: 'medium', name: 'Medium', value: 80 },
{ id: 'high', name: 'High', value: 92 },
{ id: 'max', name: 'Maximum', value: 100 },
];
const SCALE_OPTIONS = [
{ value: 0.5, label: '0.5x' },
{ value: 1, label: '1x' },
{ value: 2, label: '2x' },
{ value: 3, label: '3x' },
{ value: 4, label: '4x' },
];
const DPI_OPTIONS = [
{ value: 72, label: '72 DPI', description: 'Screen' },
{ value: 150, label: '150 DPI', description: 'Web print' },
{ value: 300, label: '300 DPI', description: 'Print' },
{ value: 600, label: '600 DPI', description: 'High quality' },
];
type PlatformPreset = {
id: string;
name: string;
icon: React.ElementType;
format: ExportFormat;
quality: ExportQuality;
maxFileSize?: string;
recommendedSize?: { width: number; height: number };
description: string;
};
const PLATFORM_PRESETS: PlatformPreset[] = [
{
id: 'instagram-post',
name: 'Instagram Post',
icon: Instagram,
format: 'jpg',
quality: 'high',
recommendedSize: { width: 1080, height: 1080 },
description: 'Square post, max 30MB',
},
{
id: 'instagram-story',
name: 'Instagram Story',
icon: Instagram,
format: 'jpg',
quality: 'high',
recommendedSize: { width: 1080, height: 1920 },
description: '9:16 vertical',
},
{
id: 'youtube-thumbnail',
name: 'YouTube Thumbnail',
icon: Youtube,
format: 'jpg',
quality: 'high',
maxFileSize: '2MB',
recommendedSize: { width: 1280, height: 720 },
description: '16:9, under 2MB',
},
{
id: 'twitter-post',
name: 'Twitter/X Post',
icon: Twitter,
format: 'png',
quality: 'high',
recommendedSize: { width: 1200, height: 675 },
description: '16:9 landscape',
},
{
id: 'facebook-post',
name: 'Facebook Post',
icon: Facebook,
format: 'jpg',
quality: 'high',
recommendedSize: { width: 1200, height: 630 },
description: '1.91:1 ratio',
},
{
id: 'linkedin-post',
name: 'LinkedIn Post',
icon: Linkedin,
format: 'png',
quality: 'high',
recommendedSize: { width: 1200, height: 627 },
description: 'Professional feed',
},
{
id: 'web-optimized',
name: 'Web Optimized',
icon: Image,
format: 'webp',
quality: 'medium',
description: 'Smallest file size',
},
{
id: 'print-ready',
name: 'Print Ready',
icon: Printer,
format: 'png',
quality: 'max',
description: 'Highest quality PNG',
},
];
type SizeMode = 'scale' | 'custom' | 'dpi';
export function ExportDialog({ open, onClose }: ExportDialogProps) {
const { project, selectedArtboardId } = useProjectStore();
const { showNotification } = useUIStore();
const [format, setFormat] = useState<ExportFormat>('png');
const [quality, setQuality] = useState<ExportQuality>('high');
const [scale, setScale] = useState(1);
const [sizeMode, setSizeMode] = useState<SizeMode>('scale');
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [customWidth, setCustomWidth] = useState(0);
const [customHeight, setCustomHeight] = useState(0);
const [dpi, setDpi] = useState(72);
const [lockAspectRatio, setLockAspectRatio] = useState(true);
const [background, setBackground] = useState<'include' | 'transparent'>('include');
const [exportAll, setExportAll] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState('');
const currentFormat = FORMATS.find((f) => f.id === format)!;
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
const effectiveScale = useMemo(() => {
if (!artboard) return 1;
if (sizeMode === 'scale') return scale;
if (sizeMode === 'custom' && customWidth > 0) {
return customWidth / artboard.size.width;
}
if (sizeMode === 'dpi') {
return dpi / 72;
}
return 1;
}, [artboard, sizeMode, scale, customWidth, dpi]);
const dimensions = useMemo(() => {
if (!artboard) return null;
if (sizeMode === 'custom') {
return { width: customWidth || artboard.size.width, height: customHeight || artboard.size.height };
}
return {
width: Math.round(artboard.size.width * effectiveScale),
height: Math.round(artboard.size.height * effectiveScale),
};
}, [artboard, sizeMode, effectiveScale, customWidth, customHeight]);
useEffect(() => {
if (artboard) {
setCustomWidth(artboard.size.width);
setCustomHeight(artboard.size.height);
}
}, [artboard?.id]);
const handleCustomWidthChange = (newWidth: number) => {
setCustomWidth(newWidth);
if (lockAspectRatio && artboard && newWidth > 0) {
const aspectRatio = artboard.size.width / artboard.size.height;
setCustomHeight(Math.round(newWidth / aspectRatio));
}
};
const handleCustomHeightChange = (newHeight: number) => {
setCustomHeight(newHeight);
if (lockAspectRatio && artboard && newHeight > 0) {
const aspectRatio = artboard.size.width / artboard.size.height;
setCustomWidth(Math.round(newHeight * aspectRatio));
}
};
const handlePresetSelect = (preset: PlatformPreset) => {
setSelectedPreset(preset.id);
setFormat(preset.format);
setQuality(preset.quality);
if (preset.recommendedSize && artboard) {
const artboardRatio = artboard.size.width / artboard.size.height;
const presetRatio = preset.recommendedSize.width / preset.recommendedSize.height;
const ratioMatch = Math.abs(artboardRatio - presetRatio) < 0.1;
if (ratioMatch) {
const targetScale = preset.recommendedSize.width / artboard.size.width;
if (targetScale <= 4 && targetScale >= 0.5) {
setScale(targetScale);
setSizeMode('scale');
} else {
setSizeMode('custom');
setCustomWidth(preset.recommendedSize.width);
setCustomHeight(preset.recommendedSize.height);
setLockAspectRatio(false);
}
}
}
};
const clearPreset = () => {
setSelectedPreset(null);
};
const printDimensions = useMemo(() => {
if (!dimensions) return null;
const inches = {
width: (dimensions.width / dpi).toFixed(2),
height: (dimensions.height / dpi).toFixed(2),
};
const cm = {
width: ((dimensions.width / dpi) * 2.54).toFixed(2),
height: ((dimensions.height / dpi) * 2.54).toFixed(2),
};
return { inches, cm };
}, [dimensions, dpi]);
const estimatedSize = useMemo(() => {
if (!dimensions) return null;
const pixels = dimensions.width * dimensions.height;
const bytesPerPixel = format === 'png' ? 3 : format === 'jpg' ? 0.5 : 0.4;
const qualityMultiplier = QUALITY_PRESETS.find((q) => q.id === quality)?.value ?? 80;
const estimated = pixels * bytesPerPixel * (qualityMultiplier / 100);
if (estimated > 1024 * 1024) {
return `~${(estimated / (1024 * 1024)).toFixed(1)} MB`;
}
return `~${Math.round(estimated / 1024)} KB`;
}, [dimensions, format, quality]);
const handleExport = async () => {
if (!project) return;
setIsExporting(true);
setProgress(0);
try {
const options: ExportOptions = {
format,
quality,
scale: effectiveScale,
background: currentFormat.supportsTransparency ? background : 'include',
artboardIds: exportAll ? undefined : selectedArtboardId ? [selectedArtboardId] : undefined,
};
const blobs = await exportProject(project, options, (p, msg) => {
setProgress(p);
setProgressMessage(msg);
});
const artboards = exportAll
? project.artboards
: project.artboards.filter((a) => a.id === selectedArtboardId);
blobs.forEach((blob, index) => {
const artboardName = artboards[index]?.name ?? `artboard-${index + 1}`;
const filename = getExportFilename(project.name, artboardName, format);
downloadBlob(blob, filename);
});
showNotification('success', `Exported ${blobs.length} artboard${blobs.length > 1 ? 's' : ''}`);
onClose();
} catch (error) {
showNotification('error', 'Export failed. Please try again.');
} finally {
setIsExporting(false);
setProgress(0);
}
};
if (!project || !artboard) return null;
return (
<Dialog
open={open}
onClose={onClose}
title="Export Image"
description="Choose format and quality settings"
maxWidth="md"
>
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Quick Presets
</label>
{selectedPreset && (
<button
onClick={clearPreset}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
)}
</div>
<div className="grid grid-cols-4 gap-2">
{PLATFORM_PRESETS.map((preset) => {
const Icon = preset.icon;
const isSelected = selectedPreset === preset.id;
return (
<button
key={preset.id}
onClick={() => handlePresetSelect(preset)}
className={`p-2 rounded-lg border text-center transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<Icon size={16} className={`mx-auto mb-1 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
<span className="block text-[10px] font-medium truncate">{preset.name}</span>
<span className="block text-[8px] text-muted-foreground truncate">{preset.description}</span>
</button>
);
})}
</div>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Format
</label>
<div className="grid grid-cols-3 gap-2">
{FORMATS.map((f) => (
<button
key={f.id}
onClick={() => setFormat(f.id)}
className={`p-3 rounded-lg border text-left transition-all ${
format === f.id
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<FileImage size={16} className={format === f.id ? 'text-primary' : 'text-muted-foreground'} />
<span className="font-medium text-sm">{f.name}</span>
</div>
<p className="text-[11px] text-muted-foreground">{f.description}</p>
</button>
))}
</div>
</div>
{currentFormat.supportsQuality && (
<div>
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Quality
</label>
<div className="grid grid-cols-4 gap-2">
{QUALITY_PRESETS.map((q) => (
<button
key={q.id}
onClick={() => setQuality(q.id)}
className={`px-3 py-2 rounded-lg border text-center transition-all ${
quality === q.id
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<span className="text-sm font-medium">{q.name}</span>
<span className="block text-[10px] text-muted-foreground">{q.value}%</span>
</button>
))}
</div>
</div>
)}
<div>
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Size
</label>
<div className="flex gap-2 mb-3">
<button
onClick={() => setSizeMode('scale')}
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
sizeMode === 'scale'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
Scale
</button>
<button
onClick={() => setSizeMode('custom')}
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
sizeMode === 'custom'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
Custom
</button>
<button
onClick={() => setSizeMode('dpi')}
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all flex items-center justify-center gap-1.5 ${
sizeMode === 'dpi'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<Printer size={14} />
Print
</button>
</div>
{sizeMode === 'scale' && (
<div className="flex gap-2">
{SCALE_OPTIONS.map((s) => (
<button
key={s.value}
onClick={() => setScale(s.value)}
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
scale === s.value
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
{s.label}
</button>
))}
</div>
)}
{sizeMode === 'custom' && (
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="block text-[10px] text-muted-foreground mb-1">Width (px)</label>
<input
type="number"
value={customWidth}
onChange={(e) => handleCustomWidthChange(Number(e.target.value))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
min={1}
max={16384}
/>
</div>
<button
onClick={() => setLockAspectRatio(!lockAspectRatio)}
className={`mt-5 p-2 rounded-lg transition-colors ${
lockAspectRatio ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
title={lockAspectRatio ? 'Unlock aspect ratio' : 'Lock aspect ratio'}
>
{lockAspectRatio ? <Link2 size={16} /> : <Link2Off size={16} />}
</button>
<div className="flex-1">
<label className="block text-[10px] text-muted-foreground mb-1">Height (px)</label>
<input
type="number"
value={customHeight}
onChange={(e) => handleCustomHeightChange(Number(e.target.value))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
min={1}
max={16384}
/>
</div>
</div>
)}
{sizeMode === 'dpi' && (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2">
{DPI_OPTIONS.map((d) => (
<button
key={d.value}
onClick={() => setDpi(d.value)}
className={`px-2 py-2 rounded-lg border text-center transition-all ${
dpi === d.value
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<span className="block text-sm font-medium">{d.value}</span>
<span className="block text-[9px] text-muted-foreground">{d.description}</span>
</button>
))}
</div>
{printDimensions && (
<div className="p-3 bg-secondary/30 rounded-lg text-xs text-muted-foreground">
<p>Print size at {dpi} DPI:</p>
<p className="font-medium text-foreground mt-1">
{printDimensions.inches.width}" × {printDimensions.inches.height}" ({printDimensions.cm.width} × {printDimensions.cm.height} cm)
</p>
</div>
)}
</div>
)}
</div>
{currentFormat.supportsTransparency && (
<div>
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Background
</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setBackground('include')}
className={`px-3 py-2.5 rounded-lg border text-sm font-medium transition-all ${
background === 'include'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
Include Background
</button>
<button
onClick={() => setBackground('transparent')}
className={`px-3 py-2.5 rounded-lg border text-sm font-medium transition-all ${
background === 'transparent'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
Transparent
</button>
</div>
</div>
)}
{project.artboards.length > 1 && (
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={exportAll}
onChange={(e) => setExportAll(e.target.checked)}
className="w-4 h-4 rounded border-border bg-background text-primary focus:ring-primary/50"
/>
<span className="text-sm">Export all artboards ({project.artboards.length})</span>
</label>
</div>
)}
<div className="p-4 bg-secondary/50 rounded-lg space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Dimensions</span>
<span className="font-medium">
{dimensions?.width} × {dimensions?.height} px
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Estimated size</span>
<span className="font-medium">{estimatedSize}</span>
</div>
</div>
{isExporting && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{progressMessage}</span>
<span className="font-medium">{Math.round(progress)}%</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
<DialogFooter>
<button
onClick={onClose}
disabled={isExporting}
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleExport}
disabled={isExporting}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{isExporting ? (
<>
<Loader2 size={16} className="animate-spin" />
Exporting...
</>
) : (
<>
<Download size={16} />
Export
</>
)}
</button>
</DialogFooter>
</Dialog>
);
}

View file

@ -0,0 +1,140 @@
import { X, Keyboard } from 'lucide-react';
interface ShortcutItem {
keys: string[];
description: string;
}
interface ShortcutGroup {
title: string;
shortcuts: ShortcutItem[];
}
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: 'Tools',
shortcuts: [
{ keys: ['V'], description: 'Select tool' },
{ keys: ['H'], description: 'Hand/Pan tool' },
{ keys: ['T'], description: 'Text tool' },
{ keys: ['S'], description: 'Shape tool' },
{ keys: ['P'], description: 'Pen tool' },
{ keys: ['I'], description: 'Eyedropper' },
{ keys: ['Z'], description: 'Zoom tool' },
],
},
{
title: 'Edit',
shortcuts: [
{ keys: ['⌘', 'Z'], description: 'Undo' },
{ keys: ['⌘', '⇧', 'Z'], description: 'Redo' },
{ keys: ['⌘', 'C'], description: 'Copy' },
{ keys: ['⌘', 'X'], description: 'Cut' },
{ keys: ['⌘', 'V'], description: 'Paste' },
{ keys: ['⌘', 'D'], description: 'Duplicate' },
{ keys: ['Delete'], description: 'Delete selected' },
],
},
{
title: 'Selection',
shortcuts: [
{ keys: ['⌘', 'A'], description: 'Select all' },
{ keys: ['Esc'], description: 'Deselect all' },
{ keys: ['⌘', 'G'], description: 'Group layers' },
{ keys: ['⌘', '⇧', 'G'], description: 'Ungroup layers' },
],
},
{
title: 'Layer Order',
shortcuts: [
{ keys: ['⌘', ']'], description: 'Bring forward' },
{ keys: ['⌘', '['], description: 'Send backward' },
{ keys: ['⌘', '⇧', ']'], description: 'Bring to front' },
{ keys: ['⌘', '⇧', '['], description: 'Send to back' },
],
},
{
title: 'View',
shortcuts: [
{ keys: ['⌘', '+'], description: 'Zoom in' },
{ keys: ['⌘', '-'], description: 'Zoom out' },
{ keys: ['⌘', '0'], description: 'Zoom to fit' },
{ keys: ["⌘", "'"], description: 'Toggle grid' },
{ keys: ['⌘', ';'], description: 'Toggle guides' },
],
},
{
title: 'Other',
shortcuts: [
{ keys: ['?'], description: 'Show shortcuts' },
{ keys: ['⌘', ','], description: 'Settings' },
],
},
];
interface Props {
isOpen: boolean;
onClose: () => void;
}
export function KeyboardShortcutsPanel({ isOpen, onClose }: Props) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Keyboard size={20} className="text-primary" />
<h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(80vh-80px)]">
<div className="grid grid-cols-2 gap-6">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.title} className="space-y-3">
<h3 className="text-sm font-medium text-foreground">{group.title}</h3>
<div className="space-y-1.5">
{group.shortcuts.map((shortcut, index) => (
<div
key={index}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<span className="text-sm text-muted-foreground">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, keyIndex) => (
<kbd
key={keyIndex}
className="min-w-[24px] h-6 px-1.5 flex items-center justify-center text-[11px] font-medium bg-secondary border border-border rounded shadow-sm"
>
{key}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="mt-6 pt-4 border-t border-border">
<p className="text-xs text-muted-foreground text-center">
Press <kbd className="px-1.5 py-0.5 bg-secondary border border-border rounded text-[10px]">?</kbd> to toggle this panel
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,217 @@
import { useState } from 'react';
import { X, Settings, Grid3X3, MousePointer, Save, Palette, Monitor } from 'lucide-react';
import { useUIStore } from '../../stores/ui-store';
import { Slider } from '@openreel/ui';
interface Props {
isOpen: boolean;
onClose: () => void;
}
type SettingsTab = 'canvas' | 'snapping' | 'appearance';
export function SettingsDialog({ isOpen, onClose }: Props) {
const [activeTab, setActiveTab] = useState<SettingsTab>('canvas');
const {
showGrid,
showGuides,
showRulers,
snapToGrid,
snapToGuides,
snapToObjects,
gridSize,
toggleGrid,
toggleGuides,
toggleRulers,
toggleSnapToGrid,
toggleSnapToGuides,
toggleSnapToObjects,
setGridSize,
} = useUIStore();
if (!isOpen) return null;
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: 'canvas', label: 'Canvas', icon: <Grid3X3 size={16} /> },
{ id: 'snapping', label: 'Snapping', icon: <MousePointer size={16} /> },
{ id: 'appearance', label: 'Appearance', icon: <Palette size={16} /> },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Settings size={20} className="text-primary" />
<h2 className="text-lg font-semibold">Settings</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
</div>
<div className="flex">
<div className="w-40 border-r border-border p-2 space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
activeTab === tab.id
? 'bg-primary/20 text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
<div className="flex-1 p-6 min-h-[300px]">
{activeTab === 'canvas' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-foreground mb-4">Canvas Options</h3>
<div className="space-y-4">
<ToggleOption
label="Show Grid"
description="Display grid overlay on canvas"
checked={showGrid}
onChange={toggleGrid}
/>
<ToggleOption
label="Show Guides"
description="Display alignment guides"
checked={showGuides}
onChange={toggleGuides}
/>
<ToggleOption
label="Show Rulers"
description="Display rulers on edges"
checked={showRulers}
onChange={toggleRulers}
/>
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-foreground">Grid Size</label>
<span className="text-sm text-muted-foreground">{gridSize}px</span>
</div>
<Slider
value={[gridSize]}
onValueChange={([value]) => setGridSize(value)}
min={5}
max={50}
step={5}
/>
</div>
</div>
</div>
)}
{activeTab === 'snapping' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-foreground mb-4">Snap Options</h3>
<div className="space-y-4">
<ToggleOption
label="Snap to Grid"
description="Snap objects to grid intersections"
checked={snapToGrid}
onChange={toggleSnapToGrid}
/>
<ToggleOption
label="Snap to Guides"
description="Snap objects to guide lines"
checked={snapToGuides}
onChange={toggleSnapToGuides}
/>
<ToggleOption
label="Snap to Objects"
description="Snap objects to other objects"
checked={snapToObjects}
onChange={toggleSnapToObjects}
/>
</div>
</div>
)}
{activeTab === 'appearance' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-foreground mb-4">Appearance</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center gap-3">
<Monitor size={18} className="text-muted-foreground" />
<div>
<p className="text-sm font-medium">Theme</p>
<p className="text-xs text-muted-foreground">Interface appearance</p>
</div>
</div>
<div className="px-3 py-1.5 text-xs bg-primary/20 text-primary rounded-md">
Dark (System)
</div>
</div>
<div className="p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center gap-3 mb-3">
<Save size={18} className="text-muted-foreground" />
<div>
<p className="text-sm font-medium">Auto Save</p>
<p className="text-xs text-muted-foreground">Automatically save projects</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Projects are automatically saved to browser storage every 30 seconds.
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
interface ToggleOptionProps {
label: string;
description: string;
checked: boolean;
onChange: () => void;
}
function ToggleOption({ label, description, checked, onChange }: ToggleOptionProps) {
return (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<button
onClick={onChange}
className={`relative w-10 h-5 rounded-full transition-colors ${
checked ? 'bg-primary' : 'bg-secondary'
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,363 @@
import { useEffect, useRef } from 'react';
import {
Copy,
Clipboard,
Scissors,
Trash2,
Eye,
EyeOff,
Lock,
Unlock,
ArrowUpToLine,
ArrowDownToLine,
ChevronUp,
ChevronDown,
FlipHorizontal,
FlipVertical,
RotateCcw,
FolderPlus,
FolderOpen,
Type,
Square,
Circle,
Triangle,
Star,
Hexagon,
Minus,
Grid3X3,
Ruler,
ZoomIn,
ZoomOut,
Maximize,
AlignLeft,
AlignCenter,
AlignRight,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
Paintbrush,
MousePointer,
} from 'lucide-react';
export interface ContextMenuPosition {
x: number;
y: number;
}
export type ContextMenuType = 'layer' | 'multi-layer' | 'canvas' | 'group';
interface MenuItem {
label: string;
icon?: React.ReactNode;
shortcut?: string;
action: () => void;
disabled?: boolean;
divider?: boolean;
submenu?: MenuItem[];
}
interface ContextMenuProps {
position: ContextMenuPosition;
type: ContextMenuType;
onClose: () => void;
onCut: () => void;
onCopy: () => void;
onPaste: () => void;
onDuplicate: () => void;
onDelete: () => void;
onSelectAll: () => void;
onToggleVisibility: () => void;
onToggleLock: () => void;
onBringToFront: () => void;
onBringForward: () => void;
onSendBackward: () => void;
onSendToBack: () => void;
onGroup: () => void;
onUngroup: () => void;
onFlipHorizontal: () => void;
onFlipVertical: () => void;
onResetTransform: () => void;
onCopyStyle: () => void;
onPasteStyle: () => void;
onAddText: () => void;
onAddShape: (type: 'rectangle' | 'ellipse' | 'triangle' | 'star' | 'polygon' | 'line') => void;
onToggleGrid: () => void;
onToggleRulers: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomFit: () => void;
onAlignLeft: () => void;
onAlignCenter: () => void;
onAlignRight: () => void;
onAlignTop: () => void;
onAlignMiddle: () => void;
onAlignBottom: () => void;
isVisible: boolean;
isLocked: boolean;
showGrid: boolean;
showRulers: boolean;
hasClipboard: boolean;
hasStyleClipboard: boolean;
selectedCount: number;
}
export function ContextMenu({
position,
type,
onClose,
onCut,
onCopy,
onPaste,
onDuplicate,
onDelete,
onSelectAll,
onToggleVisibility,
onToggleLock,
onBringToFront,
onBringForward,
onSendBackward,
onSendToBack,
onGroup,
onUngroup,
onFlipHorizontal,
onFlipVertical,
onResetTransform,
onCopyStyle,
onPasteStyle,
onAddText,
onAddShape,
onToggleGrid,
onToggleRulers,
onZoomIn,
onZoomOut,
onZoomFit,
onAlignLeft,
onAlignCenter,
onAlignRight,
onAlignTop,
onAlignMiddle,
onAlignBottom,
isVisible,
isLocked,
showGrid,
showRulers,
hasClipboard,
hasStyleClipboard,
selectedCount,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
useEffect(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = position.x;
let adjustedY = position.y;
if (position.x + rect.width > viewportWidth) {
adjustedX = viewportWidth - rect.width - 8;
}
if (position.y + rect.height > viewportHeight) {
adjustedY = viewportHeight - rect.height - 8;
}
menuRef.current.style.left = `${adjustedX}px`;
menuRef.current.style.top = `${adjustedY}px`;
}
}, [position]);
const getMenuItems = (): MenuItem[] => {
if (type === 'canvas') {
return [
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
{ label: 'Paste Style', icon: <Paintbrush size={14} />, shortcut: '⌘⇧V', action: onPasteStyle, disabled: !hasStyleClipboard },
{ label: '', action: () => {}, divider: true },
{ label: 'Select All', icon: <MousePointer size={14} />, shortcut: '⌘A', action: onSelectAll },
{ label: '', action: () => {}, divider: true },
{ label: 'Add Text', icon: <Type size={14} />, shortcut: 'T', action: onAddText },
{
label: 'Add Shape',
icon: <Square size={14} />,
action: () => {},
submenu: [
{ label: 'Rectangle', icon: <Square size={14} />, action: () => onAddShape('rectangle') },
{ label: 'Ellipse', icon: <Circle size={14} />, action: () => onAddShape('ellipse') },
{ label: 'Triangle', icon: <Triangle size={14} />, action: () => onAddShape('triangle') },
{ label: 'Star', icon: <Star size={14} />, action: () => onAddShape('star') },
{ label: 'Polygon', icon: <Hexagon size={14} />, action: () => onAddShape('polygon') },
{ label: 'Line', icon: <Minus size={14} />, action: () => onAddShape('line') },
],
},
{ label: '', action: () => {}, divider: true },
{ label: showGrid ? 'Hide Grid' : 'Show Grid', icon: <Grid3X3 size={14} />, shortcut: "⌘'", action: onToggleGrid },
{ label: showRulers ? 'Hide Rulers' : 'Show Rulers', icon: <Ruler size={14} />, shortcut: '⌘R', action: onToggleRulers },
{ label: '', action: () => {}, divider: true },
{ label: 'Zoom In', icon: <ZoomIn size={14} />, shortcut: '⌘+', action: onZoomIn },
{ label: 'Zoom Out', icon: <ZoomOut size={14} />, shortcut: '⌘-', action: onZoomOut },
{ label: 'Zoom to Fit', icon: <Maximize size={14} />, shortcut: '⌘0', action: onZoomFit },
];
}
if (type === 'multi-layer') {
return [
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
{ label: '', action: () => {}, divider: true },
{ label: `Group ${selectedCount} Layers`, icon: <FolderPlus size={14} />, shortcut: '⌘G', action: onGroup },
{ label: '', action: () => {}, divider: true },
{
label: 'Align',
icon: <AlignLeft size={14} />,
action: () => {},
submenu: [
{ label: 'Align Left', icon: <AlignLeft size={14} />, action: onAlignLeft },
{ label: 'Align Center', icon: <AlignCenter size={14} />, action: onAlignCenter },
{ label: 'Align Right', icon: <AlignRight size={14} />, action: onAlignRight },
{ label: '', action: () => {}, divider: true },
{ label: 'Align Top', icon: <AlignStartVertical size={14} />, action: onAlignTop },
{ label: 'Align Middle', icon: <AlignCenterVertical size={14} />, action: onAlignMiddle },
{ label: 'Align Bottom', icon: <AlignEndVertical size={14} />, action: onAlignBottom },
],
},
{ label: '', action: () => {}, divider: true },
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
];
}
if (type === 'group') {
return [
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
{ label: '', action: () => {}, divider: true },
{ label: 'Ungroup', icon: <FolderOpen size={14} />, shortcut: '⌘⇧G', action: onUngroup },
{ label: '', action: () => {}, divider: true },
{ label: isVisible ? 'Hide' : 'Show', icon: isVisible ? <EyeOff size={14} /> : <Eye size={14} />, shortcut: '⌘⇧H', action: onToggleVisibility },
{ label: isLocked ? 'Unlock' : 'Lock', icon: isLocked ? <Unlock size={14} /> : <Lock size={14} />, shortcut: '⌘⇧L', action: onToggleLock },
{ label: '', action: () => {}, divider: true },
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
{ label: 'Bring Forward', icon: <ChevronUp size={14} />, shortcut: '⌘]', action: onBringForward },
{ label: 'Send Backward', icon: <ChevronDown size={14} />, shortcut: '⌘[', action: onSendBackward },
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
{ label: '', action: () => {}, divider: true },
{ label: 'Flip Horizontal', icon: <FlipHorizontal size={14} />, action: onFlipHorizontal },
{ label: 'Flip Vertical', icon: <FlipVertical size={14} />, action: onFlipVertical },
{ label: 'Reset Transform', icon: <RotateCcw size={14} />, action: onResetTransform },
];
}
return [
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
{ label: '', action: () => {}, divider: true },
{ label: 'Copy Style', icon: <Paintbrush size={14} />, shortcut: '⌘⌥C', action: onCopyStyle },
{ label: 'Paste Style', icon: <Paintbrush size={14} />, shortcut: '⌘⌥V', action: onPasteStyle, disabled: !hasStyleClipboard },
{ label: '', action: () => {}, divider: true },
{ label: isVisible ? 'Hide' : 'Show', icon: isVisible ? <EyeOff size={14} /> : <Eye size={14} />, shortcut: '⌘⇧H', action: onToggleVisibility },
{ label: isLocked ? 'Unlock' : 'Lock', icon: isLocked ? <Unlock size={14} /> : <Lock size={14} />, shortcut: '⌘⇧L', action: onToggleLock },
{ label: '', action: () => {}, divider: true },
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
{ label: 'Bring Forward', icon: <ChevronUp size={14} />, shortcut: '⌘]', action: onBringForward },
{ label: 'Send Backward', icon: <ChevronDown size={14} />, shortcut: '⌘[', action: onSendBackward },
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
{ label: '', action: () => {}, divider: true },
{ label: 'Flip Horizontal', icon: <FlipHorizontal size={14} />, action: onFlipHorizontal },
{ label: 'Flip Vertical', icon: <FlipVertical size={14} />, action: onFlipVertical },
{ label: 'Reset Transform', icon: <RotateCcw size={14} />, action: onResetTransform },
];
};
const renderMenuItem = (item: MenuItem, index: number) => {
if (item.divider) {
return <div key={index} className="h-px bg-border my-1" />;
}
if (item.submenu) {
return (
<div key={index} className="relative group/submenu">
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-foreground hover:bg-accent rounded-sm transition-colors"
>
{item.icon && <span className="text-muted-foreground">{item.icon}</span>}
<span className="flex-1 text-left">{item.label}</span>
<ChevronUp size={12} className="text-muted-foreground rotate-90" />
</button>
<div className="absolute left-full top-0 ml-1 hidden group-hover/submenu:block">
<div className="bg-popover border border-border rounded-lg shadow-lg py-1 min-w-[160px]">
{item.submenu.map((subItem, subIndex) => renderMenuItem(subItem, subIndex))}
</div>
</div>
</div>
);
}
return (
<button
key={index}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs rounded-sm transition-colors ${
item.disabled
? 'text-muted-foreground/50 cursor-not-allowed'
: 'text-foreground hover:bg-accent'
}`}
>
{item.icon && <span className={item.disabled ? 'text-muted-foreground/50' : 'text-muted-foreground'}>{item.icon}</span>}
<span className="flex-1 text-left">{item.label}</span>
{item.shortcut && (
<span className="text-[10px] text-muted-foreground font-mono">{item.shortcut}</span>
)}
</button>
);
};
const menuItems = getMenuItems();
return (
<div
ref={menuRef}
className="fixed z-50 bg-popover border border-border rounded-lg shadow-xl py-1 min-w-[200px] animate-in fade-in-0 zoom-in-95 duration-100"
style={{ left: position.x, top: position.y }}
onContextMenu={(e) => e.preventDefault()}
>
{menuItems.map((item, index) => renderMenuItem(item, index))}
</div>
);
}

View file

@ -0,0 +1,206 @@
import { useEffect, useRef } from 'react';
import { useUIStore } from '../../../stores/ui-store';
import { useProjectStore } from '../../../stores/project-store';
const RULER_SIZE = 20;
const RULER_BG = '#1f1f23';
const RULER_TEXT = '#71717a';
const RULER_TICK = '#3f3f46';
const RULER_HIGHLIGHT = '#3b82f6';
interface RulersProps {
containerWidth: number;
containerHeight: number;
}
export function Rulers({ containerWidth, containerHeight }: RulersProps) {
const horizontalRef = useRef<HTMLCanvasElement>(null);
const verticalRef = useRef<HTMLCanvasElement>(null);
const cornerRef = useRef<HTMLDivElement>(null);
const { zoom, panX, panY, showRulers } = useUIStore();
const { project, selectedArtboardId } = useProjectStore();
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
useEffect(() => {
if (!showRulers || !artboard) return;
if (containerWidth <= RULER_SIZE || containerHeight <= RULER_SIZE) return;
const hCanvas = horizontalRef.current;
const vCanvas = verticalRef.current;
if (!hCanvas || !vCanvas) return;
const hCtx = hCanvas.getContext('2d');
const vCtx = vCanvas.getContext('2d');
if (!hCtx || !vCtx) return;
hCanvas.width = containerWidth - RULER_SIZE;
hCanvas.height = RULER_SIZE;
vCanvas.width = RULER_SIZE;
vCanvas.height = containerHeight - RULER_SIZE;
const centerX = containerWidth / 2 + panX;
const centerY = containerHeight / 2 + panY;
const artboardX = centerX - (artboard.size.width * zoom) / 2;
const artboardY = centerY - (artboard.size.height * zoom) / 2;
renderHorizontalRuler(hCtx, containerWidth, artboardX, artboard.size.width, zoom);
renderVerticalRuler(vCtx, containerHeight, artboardY, artboard.size.height, zoom);
}, [containerWidth, containerHeight, zoom, panX, panY, showRulers, artboard]);
if (!showRulers) return null;
return (
<>
<div
ref={cornerRef}
className="absolute top-0 left-0 z-20"
style={{
width: RULER_SIZE,
height: RULER_SIZE,
backgroundColor: RULER_BG,
borderRight: `1px solid ${RULER_TICK}`,
borderBottom: `1px solid ${RULER_TICK}`,
}}
/>
<canvas
ref={horizontalRef}
className="absolute top-0 z-10"
style={{
left: RULER_SIZE,
width: containerWidth - RULER_SIZE,
height: RULER_SIZE,
backgroundColor: RULER_BG,
}}
/>
<canvas
ref={verticalRef}
className="absolute left-0 z-10"
style={{
top: RULER_SIZE,
width: RULER_SIZE,
height: containerHeight - RULER_SIZE,
backgroundColor: RULER_BG,
}}
/>
</>
);
}
function getTickInterval(zoom: number): { major: number; minor: number } {
const baseUnit = 100;
const scaledUnit = baseUnit / zoom;
if (scaledUnit < 50) return { major: 50, minor: 10 };
if (scaledUnit < 100) return { major: 100, minor: 20 };
if (scaledUnit < 200) return { major: 100, minor: 25 };
if (scaledUnit < 500) return { major: 200, minor: 50 };
if (scaledUnit < 1000) return { major: 500, minor: 100 };
return { major: 1000, minor: 200 };
}
function renderHorizontalRuler(
ctx: CanvasRenderingContext2D,
width: number,
artboardX: number,
artboardWidth: number,
zoom: number
) {
ctx.fillStyle = RULER_BG;
ctx.fillRect(0, 0, width, RULER_SIZE);
const { major, minor } = getTickInterval(zoom);
ctx.strokeStyle = RULER_TICK;
ctx.fillStyle = RULER_TEXT;
ctx.font = '9px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const startX = -Math.ceil(artboardX / (minor * zoom)) * minor;
const endX = artboardWidth + Math.ceil((width - artboardX - artboardWidth * zoom) / (minor * zoom)) * minor;
for (let i = startX; i <= endX; i += minor) {
const screenX = artboardX + i * zoom - RULER_SIZE;
if (screenX < 0 || screenX > width) continue;
const isMajor = i % major === 0;
const tickHeight = isMajor ? 12 : 6;
ctx.beginPath();
ctx.moveTo(screenX, RULER_SIZE);
ctx.lineTo(screenX, RULER_SIZE - tickHeight);
ctx.stroke();
if (isMajor) {
ctx.fillText(String(i), screenX, 2);
}
}
const artboardStart = artboardX - RULER_SIZE;
const artboardEnd = artboardX + artboardWidth * zoom - RULER_SIZE;
ctx.strokeStyle = RULER_HIGHLIGHT;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(Math.max(0, artboardStart), RULER_SIZE - 1);
ctx.lineTo(Math.min(width, artboardEnd), RULER_SIZE - 1);
ctx.stroke();
ctx.lineWidth = 1;
}
function renderVerticalRuler(
ctx: CanvasRenderingContext2D,
height: number,
artboardY: number,
artboardHeight: number,
zoom: number
) {
ctx.fillStyle = RULER_BG;
ctx.fillRect(0, 0, RULER_SIZE, height);
const { major, minor } = getTickInterval(zoom);
ctx.strokeStyle = RULER_TICK;
ctx.fillStyle = RULER_TEXT;
ctx.font = '9px Inter, system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
const startY = -Math.ceil(artboardY / (minor * zoom)) * minor;
const endY = artboardHeight + Math.ceil((height - artboardY - artboardHeight * zoom) / (minor * zoom)) * minor;
for (let i = startY; i <= endY; i += minor) {
const screenY = artboardY + i * zoom - RULER_SIZE;
if (screenY < 0 || screenY > height) continue;
const isMajor = i % major === 0;
const tickWidth = isMajor ? 12 : 6;
ctx.beginPath();
ctx.moveTo(RULER_SIZE, screenY);
ctx.lineTo(RULER_SIZE - tickWidth, screenY);
ctx.stroke();
if (isMajor) {
ctx.save();
ctx.translate(10, screenY);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = 'center';
ctx.fillText(String(i), 0, 0);
ctx.restore();
}
}
const artboardStart = artboardY - RULER_SIZE;
const artboardEnd = artboardY + artboardHeight * zoom - RULER_SIZE;
ctx.strokeStyle = RULER_HIGHLIGHT;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(RULER_SIZE - 1, Math.max(0, artboardStart));
ctx.lineTo(RULER_SIZE - 1, Math.min(height, artboardEnd));
ctx.stroke();
ctx.lineWidth = 1;
}

View file

@ -0,0 +1,240 @@
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import {
AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignHorizontalSpaceBetween,
AlignVerticalSpaceBetween,
} from 'lucide-react';
interface Props {
layers: Layer[];
}
export function AlignmentSection({ layers }: Props) {
const { project, selectedArtboardId, updateLayerTransform } = useProjectStore();
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
if (!artboard || layers.length === 0) return null;
const alignLeft = () => {
if (layers.length === 1) {
updateLayerTransform(layers[0].id, { x: 0 });
} else {
const minX = Math.min(...layers.map((l) => l.transform.x));
layers.forEach((layer) => {
updateLayerTransform(layer.id, { x: minX });
});
}
};
const alignCenterH = () => {
if (layers.length === 1) {
const layer = layers[0];
updateLayerTransform(layer.id, {
x: (artboard.size.width - layer.transform.width) / 2,
});
} else {
const bounds = getBounds(layers);
const centerX = bounds.x + bounds.width / 2;
layers.forEach((layer) => {
updateLayerTransform(layer.id, {
x: centerX - layer.transform.width / 2,
});
});
}
};
const alignRight = () => {
if (layers.length === 1) {
const layer = layers[0];
updateLayerTransform(layer.id, {
x: artboard.size.width - layer.transform.width,
});
} else {
const maxRight = Math.max(...layers.map((l) => l.transform.x + l.transform.width));
layers.forEach((layer) => {
updateLayerTransform(layer.id, {
x: maxRight - layer.transform.width,
});
});
}
};
const alignTop = () => {
if (layers.length === 1) {
updateLayerTransform(layers[0].id, { y: 0 });
} else {
const minY = Math.min(...layers.map((l) => l.transform.y));
layers.forEach((layer) => {
updateLayerTransform(layer.id, { y: minY });
});
}
};
const alignCenterV = () => {
if (layers.length === 1) {
const layer = layers[0];
updateLayerTransform(layer.id, {
y: (artboard.size.height - layer.transform.height) / 2,
});
} else {
const bounds = getBounds(layers);
const centerY = bounds.y + bounds.height / 2;
layers.forEach((layer) => {
updateLayerTransform(layer.id, {
y: centerY - layer.transform.height / 2,
});
});
}
};
const alignBottom = () => {
if (layers.length === 1) {
const layer = layers[0];
updateLayerTransform(layer.id, {
y: artboard.size.height - layer.transform.height,
});
} else {
const maxBottom = Math.max(...layers.map((l) => l.transform.y + l.transform.height));
layers.forEach((layer) => {
updateLayerTransform(layer.id, {
y: maxBottom - layer.transform.height,
});
});
}
};
const distributeH = () => {
if (layers.length < 3) return;
const sorted = [...layers].sort((a, b) => a.transform.x - b.transform.x);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalWidth = last.transform.x + last.transform.width - first.transform.x;
const layersWidth = sorted.reduce((sum, l) => sum + l.transform.width, 0);
const gap = (totalWidth - layersWidth) / (sorted.length - 1);
let x = first.transform.x;
sorted.forEach((layer) => {
updateLayerTransform(layer.id, { x });
x += layer.transform.width + gap;
});
};
const distributeV = () => {
if (layers.length < 3) return;
const sorted = [...layers].sort((a, b) => a.transform.y - b.transform.y);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalHeight = last.transform.y + last.transform.height - first.transform.y;
const layersHeight = sorted.reduce((sum, l) => sum + l.transform.height, 0);
const gap = (totalHeight - layersHeight) / (sorted.length - 1);
let y = first.transform.y;
sorted.forEach((layer) => {
updateLayerTransform(layer.id, { y });
y += layer.transform.height + gap;
});
};
const isSingleLayer = layers.length === 1;
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Alignment
</h4>
<div className="grid grid-cols-6 gap-1">
<AlignButton
icon={<AlignHorizontalJustifyStart size={14} />}
onClick={alignLeft}
title={isSingleLayer ? 'Align to canvas left' : 'Align left edges'}
/>
<AlignButton
icon={<AlignHorizontalJustifyCenter size={14} />}
onClick={alignCenterH}
title={isSingleLayer ? 'Center horizontally on canvas' : 'Align horizontal centers'}
/>
<AlignButton
icon={<AlignHorizontalJustifyEnd size={14} />}
onClick={alignRight}
title={isSingleLayer ? 'Align to canvas right' : 'Align right edges'}
/>
<AlignButton
icon={<AlignVerticalJustifyStart size={14} />}
onClick={alignTop}
title={isSingleLayer ? 'Align to canvas top' : 'Align top edges'}
/>
<AlignButton
icon={<AlignVerticalJustifyCenter size={14} />}
onClick={alignCenterV}
title={isSingleLayer ? 'Center vertically on canvas' : 'Align vertical centers'}
/>
<AlignButton
icon={<AlignVerticalJustifyEnd size={14} />}
onClick={alignBottom}
title={isSingleLayer ? 'Align to canvas bottom' : 'Align bottom edges'}
/>
</div>
{layers.length >= 3 && (
<div className="grid grid-cols-2 gap-1 pt-2 border-t border-border">
<AlignButton
icon={<AlignHorizontalSpaceBetween size={14} />}
onClick={distributeH}
title="Distribute horizontally"
label="Distribute H"
/>
<AlignButton
icon={<AlignVerticalSpaceBetween size={14} />}
onClick={distributeV}
title="Distribute vertically"
label="Distribute V"
/>
</div>
)}
</div>
);
}
interface AlignButtonProps {
icon: React.ReactNode;
onClick: () => void;
title: string;
label?: string;
}
function AlignButton({ icon, onClick, title, label }: AlignButtonProps) {
return (
<button
onClick={onClick}
title={title}
className="flex items-center justify-center gap-1 p-2 rounded-md bg-secondary/50 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
>
{icon}
{label && <span className="text-[9px]">{label}</span>}
</button>
);
}
function getBounds(layers: Layer[]): { x: number; y: number; width: number; height: number } {
const minX = Math.min(...layers.map((l) => l.transform.x));
const minY = Math.min(...layers.map((l) => l.transform.y));
const maxX = Math.max(...layers.map((l) => l.transform.x + l.transform.width));
const maxY = Math.max(...layers.map((l) => l.transform.y + l.transform.height));
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}

View file

@ -0,0 +1,180 @@
import { useProjectStore } from '../../../stores/project-store';
import type { Layer, BlendMode } from '../../../types/project';
interface Props {
layer: Layer;
}
const BLEND_MODES: BlendMode['mode'][] = [
'normal',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
];
export function AppearanceSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const handleBlendModeChange = (mode: BlendMode['mode']) => {
updateLayer(layer.id, { blendMode: { mode } });
};
const handleShadowToggle = () => {
updateLayer(layer.id, {
shadow: { ...layer.shadow, enabled: !layer.shadow.enabled },
});
};
const handleShadowChange = (key: string, value: string | number) => {
updateLayer(layer.id, {
shadow: { ...layer.shadow, [key]: value },
});
};
const handleStrokeToggle = () => {
updateLayer(layer.id, {
stroke: { ...layer.stroke, enabled: !layer.stroke.enabled },
});
};
const handleStrokeChange = (key: string, value: string | number) => {
updateLayer(layer.id, {
stroke: { ...layer.stroke, [key]: value },
});
};
return (
<div className="space-y-4">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Blend Mode</label>
<select
value={layer.blendMode.mode}
onChange={(e) => handleBlendModeChange(e.target.value as BlendMode['mode'])}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary capitalize"
>
{BLEND_MODES.map((mode) => (
<option key={mode} value={mode} className="capitalize">
{mode.replace('-', ' ')}
</option>
))}
</select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground">Drop Shadow</label>
<button
onClick={handleShadowToggle}
className={`w-8 h-5 rounded-full transition-colors ${
layer.shadow.enabled ? 'bg-primary' : 'bg-secondary'
}`}
>
<div
className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
layer.shadow.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{layer.shadow.enabled && (
<div className="pl-2 border-l-2 border-border space-y-2">
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">Color</label>
<input
type="color"
value={layer.shadow.color.replace(/rgba?\([^)]+\)/, '#000000')}
onChange={(e) => handleShadowChange('color', e.target.value)}
className="w-6 h-6 rounded border border-input cursor-pointer"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">Blur</label>
<input
type="range"
value={layer.shadow.blur}
onChange={(e) => handleShadowChange('blur', Number(e.target.value))}
min={0}
max={50}
className="flex-1 h-1 accent-primary"
/>
<span className="text-[10px] text-muted-foreground w-6 text-right">
{layer.shadow.blur}
</span>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">X</label>
<input
type="number"
value={layer.shadow.offsetX}
onChange={(e) => handleShadowChange('offsetX', Number(e.target.value))}
className="w-16 px-2 py-1 text-xs bg-background border border-input rounded-md"
/>
<label className="text-[10px] text-muted-foreground w-4">Y</label>
<input
type="number"
value={layer.shadow.offsetY}
onChange={(e) => handleShadowChange('offsetY', Number(e.target.value))}
className="w-16 px-2 py-1 text-xs bg-background border border-input rounded-md"
/>
</div>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground">Stroke</label>
<button
onClick={handleStrokeToggle}
className={`w-8 h-5 rounded-full transition-colors ${
layer.stroke.enabled ? 'bg-primary' : 'bg-secondary'
}`}
>
<div
className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
layer.stroke.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{layer.stroke.enabled && (
<div className="pl-2 border-l-2 border-border space-y-2">
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">Color</label>
<input
type="color"
value={layer.stroke.color}
onChange={(e) => handleStrokeChange('color', e.target.value)}
className="w-6 h-6 rounded border border-input cursor-pointer"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">Width</label>
<input
type="range"
value={layer.stroke.width}
onChange={(e) => handleStrokeChange('width', Number(e.target.value))}
min={1}
max={20}
className="flex-1 h-1 accent-primary"
/>
<span className="text-[10px] text-muted-foreground w-6 text-right">
{layer.stroke.width}
</span>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,136 @@
import { useProjectStore } from '../../../stores/project-store';
import type { Artboard, CanvasBackground } from '../../../types/project';
interface Props {
artboard: Artboard;
}
export function ArtboardSection({ artboard }: Props) {
const { updateArtboard } = useProjectStore();
const handleSizeChange = (key: 'width' | 'height', value: number) => {
updateArtboard(artboard.id, {
size: { ...artboard.size, [key]: value },
});
};
const handleBackgroundTypeChange = (type: CanvasBackground['type']) => {
let background: CanvasBackground;
switch (type) {
case 'color':
background = { type: 'color', color: '#ffffff' };
break;
case 'transparent':
background = { type: 'transparent' };
break;
case 'gradient':
background = {
type: 'gradient',
gradient: {
type: 'linear',
angle: 180,
stops: [
{ offset: 0, color: '#ffffff' },
{ offset: 1, color: '#000000' },
],
},
};
break;
default:
background = { type: 'color', color: '#ffffff' };
}
updateArtboard(artboard.id, { background });
};
const handleBackgroundColorChange = (color: string) => {
updateArtboard(artboard.id, {
background: { type: 'color', color },
});
};
return (
<div className="space-y-4">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Name</label>
<input
type="text"
value={artboard.name}
onChange={(e) => updateArtboard(artboard.id, { name: e.target.value })}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Width</label>
<input
type="number"
value={artboard.size.width}
onChange={(e) => handleSizeChange('width', Number(e.target.value))}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
min={1}
max={8000}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Height</label>
<input
type="number"
value={artboard.size.height}
onChange={(e) => handleSizeChange('height', Number(e.target.value))}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
min={1}
max={8000}
/>
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Background</label>
<select
value={artboard.background.type}
onChange={(e) => handleBackgroundTypeChange(e.target.value as CanvasBackground['type'])}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary mb-2"
>
<option value="color">Solid Color</option>
<option value="transparent">Transparent</option>
<option value="gradient">Gradient</option>
</select>
{artboard.background.type === 'color' && (
<div className="flex items-center gap-2">
<input
type="color"
value={artboard.background.color ?? '#ffffff'}
onChange={(e) => handleBackgroundColorChange(e.target.value)}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={artboard.background.color ?? '#ffffff'}
onChange={(e) => handleBackgroundColorChange(e.target.value)}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</div>
)}
{artboard.background.type === 'transparent' && (
<div className="p-3 rounded-md bg-background border border-input">
<div
className="h-8 rounded"
style={{
backgroundImage:
'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)',
backgroundSize: '10px 10px',
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
}}
/>
<p className="text-[10px] text-muted-foreground mt-2 text-center">
Transparency pattern
</p>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,169 @@
import { useState } from 'react';
import { Wand2, Loader2 } from 'lucide-react';
import { Slider } from '@openreel/ui';
import { useProjectStore } from '../../../stores/project-store';
import type { ImageLayer } from '../../../types/project';
import {
getBackgroundRemovalService,
BackgroundMode,
DEFAULT_OPTIONS,
} from '../../../services/background-removal-service';
interface Props {
layer: ImageLayer;
}
export function BackgroundRemovalSection({ layer }: Props) {
const { project, addAsset, updateLayer } = useProjectStore();
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [mode, setMode] = useState<BackgroundMode>('transparent');
const [backgroundColor, setBackgroundColor] = useState(DEFAULT_OPTIONS.backgroundColor!);
const [blurAmount, setBlurAmount] = useState(DEFAULT_OPTIONS.blurAmount!);
const asset = project?.assets[layer.sourceId];
const handleRemoveBackground = async () => {
if (!asset?.dataUrl && !asset?.thumbnailUrl) return;
setIsProcessing(true);
setProgress(0);
try {
const service = getBackgroundRemovalService();
const imageUrl = asset.dataUrl || asset.thumbnailUrl;
const resultDataUrl = await service.removeBackground(
imageUrl,
{
mode,
backgroundColor,
blurAmount,
},
setProgress
);
const newAssetId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
addAsset({
id: newAssetId,
name: `${asset.name} (no bg)`,
type: 'image',
mimeType: 'image/png',
size: 0,
width: asset.width,
height: asset.height,
thumbnailUrl: resultDataUrl,
dataUrl: resultDataUrl,
});
updateLayer<ImageLayer>(layer.id, { sourceId: newAssetId });
} catch (error) {
console.error('Background removal failed:', error);
} finally {
setIsProcessing(false);
setProgress(0);
}
};
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Background Removal
</h4>
<div className="p-3 space-y-4 bg-secondary/50 rounded-lg">
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground">Mode</label>
<div className="grid grid-cols-3 gap-1">
{(['transparent', 'color', 'blur'] as BackgroundMode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-2 py-1.5 text-[10px] font-medium rounded transition-colors ${
mode === m
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
))}
</div>
</div>
{mode === 'color' && (
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground">Background</label>
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1 px-2 py-1 text-xs bg-background border border-input rounded-md font-mono"
/>
</div>
)}
{mode === 'blur' && (
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur Amount</label>
<span className="text-[10px] text-muted-foreground">{blurAmount}px</span>
</div>
<Slider
value={[blurAmount]}
onValueChange={([v]) => setBlurAmount(v)}
min={5}
max={30}
step={1}
/>
</div>
)}
{isProcessing && (
<div className="space-y-1.5">
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground text-center">
{progress < 15 ? 'Loading AI model...' :
progress < 90 ? 'Analyzing image...' :
'Finalizing...'}
{' '}{Math.round(progress)}%
</p>
</div>
)}
<button
onClick={handleRemoveBackground}
disabled={isProcessing}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isProcessing ? (
<>
<Loader2 size={16} className="animate-spin" />
Processing...
</>
) : (
<>
<Wand2 size={16} />
Remove Background
</>
)}
</button>
<p className="text-[9px] text-muted-foreground text-center">
AI-powered background removal for any image
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,226 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { BlackWhiteAdjustment } from '../../../types/adjustments';
import { DEFAULT_BLACK_WHITE } from '../../../types/adjustments';
import { BLACK_WHITE_PRESETS } from '../../../adjustments/black-white';
import { SunMoon, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
const COLOR_SLIDERS: { key: keyof BlackWhiteAdjustment; label: string; color: string }[] = [
{ key: 'reds', label: 'Reds', color: 'bg-red-500' },
{ key: 'yellows', label: 'Yellows', color: 'bg-yellow-500' },
{ key: 'greens', label: 'Greens', color: 'bg-green-500' },
{ key: 'cyans', label: 'Cyans', color: 'bg-cyan-500' },
{ key: 'blues', label: 'Blues', color: 'bg-blue-500' },
{ key: 'magentas', label: 'Magentas', color: 'bg-pink-500' },
];
const PRESET_OPTIONS = [
{ id: 'default', label: 'Default' },
{ id: 'highContrast', label: 'High Contrast' },
{ id: 'infrared', label: 'Infrared' },
{ id: 'maximumBlack', label: 'Maximum Black' },
{ id: 'maximumWhite', label: 'Maximum White' },
{ id: 'neutralDensity', label: 'Neutral Density' },
{ id: 'redFilter', label: 'Red Filter' },
{ id: 'yellowFilter', label: 'Yellow Filter' },
{ id: 'greenFilter', label: 'Green Filter' },
{ id: 'blueFilter', label: 'Blue Filter' },
] as const;
function ChannelSlider({ label, color, value, onChange }: { label: string; color: string; value: number; onChange: (v: number) => void }) {
const percentage = ((value + 200) / 400) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-[10px] text-muted-foreground">{label}</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground">{value}%</span>
</div>
<input
type="range"
value={value}
min={-200}
max={200}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--secondary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
}}
/>
</div>
);
}
export function BlackWhiteSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const blackWhite = layer.blackWhite;
const handleValueChange = (key: keyof BlackWhiteAdjustment, value: number | boolean) => {
updateLayer(layer.id, {
blackWhite: {
...blackWhite,
[key]: value,
},
});
};
const handlePresetChange = (presetId: string) => {
const preset = BLACK_WHITE_PRESETS[presetId as keyof typeof BLACK_WHITE_PRESETS];
if (preset) {
updateLayer(layer.id, {
blackWhite: {
...blackWhite,
...preset,
},
});
}
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
blackWhite: {
...blackWhite,
enabled,
},
});
};
const resetBlackWhite = () => {
updateLayer(layer.id, {
blackWhite: { ...DEFAULT_BLACK_WHITE },
});
};
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<SunMoon size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Black & White</span>
{blackWhite.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={blackWhite.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<select
value=""
onChange={(e) => handlePresetChange(e.target.value)}
className="text-[10px] bg-secondary border-none rounded px-2 py-1 text-foreground"
>
<option value="">Preset</option>
{PRESET_OPTIONS.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.label}</option>
))}
</select>
<button
onClick={resetBlackWhite}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-2">
{COLOR_SLIDERS.map(({ key, label, color }) => (
<ChannelSlider
key={key}
label={label}
color={color}
value={blackWhite[key] as number}
onChange={(v) => handleValueChange(key, v)}
/>
))}
</div>
<div className="space-y-2 pt-2 border-t border-border/50">
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={blackWhite.tintEnabled}
onChange={(e) => handleValueChange('tintEnabled', e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Tint
</label>
{blackWhite.tintEnabled && (
<div className="space-y-2 pl-5">
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Hue</span>
<span className="text-[10px] font-mono text-muted-foreground">{blackWhite.tintHue}°</span>
</div>
<input
type="range"
value={blackWhite.tintHue}
min={0}
max={360}
onChange={(e) => handleValueChange('tintHue', Number(e.target.value))}
className="w-full h-1.5 appearance-none rounded-full cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(0, 70%, 50%), hsl(60, 70%, 50%), hsl(120, 70%, 50%), hsl(180, 70%, 50%), hsl(240, 70%, 50%), hsl(300, 70%, 50%), hsl(360, 70%, 50%))`
}}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Saturation</span>
<span className="text-[10px] font-mono text-muted-foreground">{blackWhite.tintSaturation}%</span>
</div>
<input
type="range"
value={blackWhite.tintSaturation}
min={0}
max={100}
onChange={(e) => handleValueChange('tintSaturation', Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
/>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,121 @@
import { useUIStore } from '../../../stores/ui-store';
import { Droplets, RotateCcw } from 'lucide-react';
export function BlurSharpenToolPanel() {
const { blurSharpenSettings, setBlurSharpenSettings } = useUIStore();
const resetSettings = () => {
setBlurSharpenSettings({
size: 30,
strength: 50,
mode: 'blur',
sampleAllLayers: false,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Droplets size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">
{blurSharpenSettings.mode === 'blur' ? 'Blur' : 'Sharpen'} Tool
</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
{blurSharpenSettings.mode === 'blur'
? 'Paint to blur and soften areas.'
: 'Paint to sharpen and enhance details.'}
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => setBlurSharpenSettings({ mode: 'blur' })}
className={`px-3 py-2 text-xs rounded transition-colors ${
blurSharpenSettings.mode === 'blur'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
Blur
</button>
<button
onClick={() => setBlurSharpenSettings({ mode: 'sharpen' })}
className={`px-3 py-2 text-xs rounded transition-colors ${
blurSharpenSettings.mode === 'sharpen'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
Sharpen
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{blurSharpenSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={blurSharpenSettings.size}
onChange={(e) => setBlurSharpenSettings({ size: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Strength</span>
<span className="text-xs font-mono text-muted-foreground">{blurSharpenSettings.strength}%</span>
</div>
<input
type="range"
min={1}
max={100}
value={blurSharpenSettings.strength}
onChange={(e) => setBlurSharpenSettings({ strength: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div className="pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={blurSharpenSettings.sampleAllLayers}
onChange={(e) => setBlurSharpenSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Sample All Layers
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,156 @@
import { useUIStore } from '../../../stores/ui-store';
import { Paintbrush, RotateCcw } from 'lucide-react';
export function BrushToolPanel() {
const { brushSettings, setBrushSettings } = useUIStore();
const resetSettings = () => {
setBrushSettings({
size: 20,
hardness: 100,
opacity: 1,
flow: 1,
color: '#000000',
blendMode: 'normal',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Paintbrush size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Brush</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Color</span>
</div>
<div className="flex gap-2">
<input
type="color"
value={brushSettings.color}
onChange={(e) => setBrushSettings({ color: e.target.value })}
className="w-10 h-10 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={brushSettings.color}
onChange={(e) => setBrushSettings({ color: e.target.value })}
className="flex-1 px-2 py-1 text-xs font-mono bg-secondary/50 border border-border rounded"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{brushSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={brushSettings.size}
onChange={(e) => setBrushSettings({ size: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Hardness</span>
<span className="text-xs font-mono text-muted-foreground">{brushSettings.hardness}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={brushSettings.hardness}
onChange={(e) => setBrushSettings({ hardness: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(brushSettings.opacity * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={brushSettings.opacity * 100}
onChange={(e) => setBrushSettings({ opacity: Number(e.target.value) / 100 })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Flow</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(brushSettings.flow * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={brushSettings.flow * 100}
onChange={(e) => setBrushSettings({ flow: Number(e.target.value) / 100 })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Blend Mode</span>
<div className="grid grid-cols-2 gap-1">
{(['normal', 'multiply', 'screen', 'overlay'] as const).map((mode) => (
<button
key={mode}
onClick={() => setBrushSettings({ blendMode: mode })}
className={`px-2 py-1.5 text-xs rounded transition-colors capitalize ${
brushSettings.blendMode === mode
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{mode}
</button>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,170 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { ChannelMixerAdjustment, ChannelMixerChannel } from '../../../types/adjustments';
import { DEFAULT_CHANNEL_MIXER } from '../../../types/adjustments';
import { Blend, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type OutputChannel = 'red' | 'green' | 'blue';
const CHANNEL_COLORS: Record<OutputChannel, string> = {
red: 'bg-red-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
};
function ChannelSlider({ label, color, value, onChange }: { label: string; color: string; value: number; onChange: (v: number) => void }) {
const percentage = ((value + 200) / 400) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-[10px] text-muted-foreground">{label}</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground">{value}%</span>
</div>
<input
type="range"
value={value}
min={-200}
max={200}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--secondary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
}}
/>
</div>
);
}
export function ChannelMixerSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeChannel, setActiveChannel] = useState<OutputChannel>('red');
const [isExpanded, setIsExpanded] = useState(false);
const channelMixer = layer.channelMixer;
const currentChannel = channelMixer[activeChannel];
const handleValueChange = (key: keyof ChannelMixerChannel, value: number) => {
updateLayer(layer.id, {
channelMixer: {
...channelMixer,
[activeChannel]: {
...currentChannel,
[key]: value,
},
} as ChannelMixerAdjustment,
});
};
const handleMonochromeChange = (monochrome: boolean) => {
updateLayer(layer.id, {
channelMixer: {
...channelMixer,
monochrome,
} as ChannelMixerAdjustment,
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
channelMixer: {
...channelMixer,
enabled,
} as ChannelMixerAdjustment,
});
};
const resetChannelMixer = () => {
updateLayer(layer.id, {
channelMixer: { ...DEFAULT_CHANNEL_MIXER },
});
};
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Blend size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Channel Mixer</span>
{channelMixer.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={channelMixer.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{(['red', 'green', 'blue'] as OutputChannel[]).map((channel) => (
<button
key={channel}
onClick={() => setActiveChannel(channel)}
className={`px-2 py-1 text-[10px] rounded transition-colors ${
activeChannel === channel
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className={`inline-block w-2 h-2 rounded-full mr-1 ${CHANNEL_COLORS[channel]}`} />
{channel.charAt(0).toUpperCase() + channel.slice(1)}
</button>
))}
</div>
<button
onClick={resetChannelMixer}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-2">
<ChannelSlider label="Red" color="bg-red-500" value={currentChannel.red} onChange={(v) => handleValueChange('red', v)} />
<ChannelSlider label="Green" color="bg-green-500" value={currentChannel.green} onChange={(v) => handleValueChange('green', v)} />
<ChannelSlider label="Blue" color="bg-blue-500" value={currentChannel.blue} onChange={(v) => handleValueChange('blue', v)} />
<ChannelSlider label="Constant" color="bg-gray-500" value={currentChannel.constant} onChange={(v) => handleValueChange('constant', v)} />
</div>
<label className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-border/50">
<input
type="checkbox"
checked={channelMixer.monochrome}
onChange={(e) => handleMonochromeChange(e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Monochrome
</label>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,149 @@
import { useUIStore } from '../../../stores/ui-store';
import { Stamp, RotateCcw } from 'lucide-react';
export function CloneStampToolPanel() {
const { cloneStampSettings, setCloneStampSettings } = useUIStore();
const resetSettings = () => {
setCloneStampSettings({
size: 30,
hardness: 50,
opacity: 1,
flow: 1,
aligned: true,
sampleAllLayers: false,
sourcePoint: null,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Stamp size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Clone Stamp</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Hold Alt/Option and click to set source point, then paint to clone.
</p>
{cloneStampSettings.sourcePoint && (
<div className="text-xs text-muted-foreground bg-secondary/50 p-2 rounded">
Source: ({Math.round(cloneStampSettings.sourcePoint.x)}, {Math.round(cloneStampSettings.sourcePoint.y)})
</div>
)}
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{cloneStampSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={cloneStampSettings.size}
onChange={(e) => setCloneStampSettings({ size: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Hardness</span>
<span className="text-xs font-mono text-muted-foreground">{cloneStampSettings.hardness}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={cloneStampSettings.hardness}
onChange={(e) => setCloneStampSettings({ hardness: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(cloneStampSettings.opacity * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={cloneStampSettings.opacity * 100}
onChange={(e) => setCloneStampSettings({ opacity: Number(e.target.value) / 100 })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Flow</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(cloneStampSettings.flow * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={cloneStampSettings.flow * 100}
onChange={(e) => setCloneStampSettings({ flow: Number(e.target.value) / 100 })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div className="flex flex-col gap-2 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={cloneStampSettings.aligned}
onChange={(e) => setCloneStampSettings({ aligned: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Aligned
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={cloneStampSettings.sampleAllLayers}
onChange={(e) => setCloneStampSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Sample All Layers
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,211 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { ColorBalanceValues } from '../../../types/adjustments';
import { DEFAULT_COLOR_BALANCE } from '../../../types/adjustments';
import { Palette, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type ToneType = 'shadows' | 'midtones' | 'highlights';
interface BalanceSliderProps {
leftLabel: string;
rightLabel: string;
leftColor: string;
rightColor: string;
value: number;
onChange: (value: number) => void;
}
function BalanceSlider({
leftLabel,
rightLabel,
leftColor,
rightColor,
value,
onChange,
}: BalanceSliderProps) {
const percentage = ((value + 100) / 200) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px]" style={{ color: leftColor }}>
{leftLabel}
</span>
<span className="text-[10px] font-mono text-muted-foreground">{value}</span>
<span className="text-[10px]" style={{ color: rightColor }}>
{rightLabel}
</span>
</div>
<input
type="range"
value={value}
min={-100}
max={100}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-foreground
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, ${leftColor} 0%, hsl(var(--secondary)) ${percentage}%, ${rightColor} 100%)`,
}}
/>
</div>
);
}
export function ColorBalanceSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeTone, setActiveTone] = useState<ToneType>('midtones');
const [isExpanded, setIsExpanded] = useState(true);
const colorBalance = layer.colorBalance;
const currentTone = colorBalance[activeTone];
const handleToneChange = (key: keyof ColorBalanceValues, value: number) => {
updateLayer(layer.id, {
colorBalance: {
...colorBalance,
[activeTone]: {
...currentTone,
[key]: value,
},
},
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
colorBalance: {
...colorBalance,
enabled,
},
});
};
const handlePreserveLuminosityChange = (preserveLuminosity: boolean) => {
updateLayer(layer.id, {
colorBalance: {
...colorBalance,
preserveLuminosity,
},
});
};
const resetColorBalance = () => {
updateLayer(layer.id, {
colorBalance: { ...DEFAULT_COLOR_BALANCE },
});
};
const tones: { id: ToneType; label: string }[] = [
{ id: 'shadows', label: 'Shadows' },
{ id: 'midtones', label: 'Midtones' },
{ id: 'highlights', label: 'Highlights' },
];
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Color Balance</span>
{colorBalance.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<div className="flex items-center gap-1">
<input
type="checkbox"
checked={colorBalance.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{tones.map((tone) => (
<button
key={tone.id}
onClick={() => setActiveTone(tone.id)}
className={`px-2 py-1 text-[10px] rounded transition-colors ${
activeTone === tone.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{tone.label}
</button>
))}
</div>
<button
onClick={resetColorBalance}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset Color Balance"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-3 pt-1">
<BalanceSlider
leftLabel="Cyan"
rightLabel="Red"
leftColor="#00bcd4"
rightColor="#f44336"
value={currentTone.cyanRed}
onChange={(v) => handleToneChange('cyanRed', v)}
/>
<BalanceSlider
leftLabel="Magenta"
rightLabel="Green"
leftColor="#e91e63"
rightColor="#4caf50"
value={currentTone.magentaGreen}
onChange={(v) => handleToneChange('magentaGreen', v)}
/>
<BalanceSlider
leftLabel="Yellow"
rightLabel="Blue"
leftColor="#ffeb3b"
rightColor="#2196f3"
value={currentTone.yellowBlue}
onChange={(v) => handleToneChange('yellowBlue', v)}
/>
</div>
<label className="flex items-center gap-2 pt-2 border-t border-border">
<input
type="checkbox"
checked={colorBalance.preserveLuminosity}
onChange={(e) => handlePreserveLuminosityChange(e.target.checked)}
className="w-3.5 h-3.5 rounded border-border"
/>
<span className="text-[10px] text-muted-foreground">Preserve Luminosity</span>
</label>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,107 @@
import { useState } from 'react';
import { getAllHarmonies, type HarmonyType } from '../../../utils/color-harmony';
import { Palette, Copy, Check } from 'lucide-react';
import { ColorPalettes, QuickColorSwatches } from '../../ui/ColorPalettes';
import { SavedColorsSection } from '../../ui/SavedColorsSection';
import { useColorStore } from '../../../stores/color-store';
interface Props {
baseColor: string;
onColorSelect?: (color: string) => void;
}
export function ColorHarmonySection({ baseColor, onColorSelect }: Props) {
const [copiedColor, setCopiedColor] = useState<string | null>(null);
const [selectedHarmony, setSelectedHarmony] = useState<HarmonyType>('complementary');
const { addRecentColor } = useColorStore();
const isValidHex = /^#[0-9A-Fa-f]{6}$/.test(baseColor);
if (!isValidHex) return null;
const harmonies = getAllHarmonies(baseColor);
const activeHarmony = harmonies.find((h) => h.type === selectedHarmony) ?? harmonies[0];
const handleColorSelect = (color: string) => {
addRecentColor(color);
onColorSelect?.(color);
};
const handleCopyColor = async (color: string) => {
try {
await navigator.clipboard.writeText(color);
setCopiedColor(color);
setTimeout(() => setCopiedColor(null), 1500);
} catch {
// Clipboard API not available
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Color Harmony
</h4>
</div>
<div className="flex flex-wrap gap-1">
{harmonies.map((harmony) => (
<button
key={harmony.type}
onClick={() => setSelectedHarmony(harmony.type)}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
selectedHarmony === harmony.type
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
{harmony.name}
</button>
))}
</div>
<div className="p-3 bg-secondary/50 rounded-lg space-y-2">
<div className="flex gap-1.5">
{activeHarmony.colors.map((color, index) => (
<div key={index} className="flex-1 flex flex-col items-center gap-1">
<button
onClick={() => handleColorSelect(color)}
className="w-full aspect-square rounded-lg border border-border hover:ring-2 hover:ring-primary/50 transition-all cursor-pointer"
style={{ backgroundColor: color }}
title={`Click to apply ${color}`}
/>
<button
onClick={() => handleCopyColor(color)}
className="flex items-center gap-1 text-[9px] text-muted-foreground hover:text-foreground transition-colors"
>
{copiedColor === color ? (
<Check size={10} className="text-green-500" />
) : (
<Copy size={10} />
)}
<span className="font-mono">{color.toUpperCase()}</span>
</button>
</div>
))}
</div>
<p className="text-[9px] text-muted-foreground text-center">
Click a color to apply, or copy its hex code
</p>
</div>
{onColorSelect && (
<>
<SavedColorsSection
onColorSelect={handleColorSelect}
selectedColor={baseColor}
currentColor={baseColor}
/>
<QuickColorSwatches onColorSelect={handleColorSelect} selectedColor={baseColor} />
<ColorPalettes onColorSelect={handleColorSelect} selectedColor={baseColor} />
</>
)}
</div>
);
}

View file

@ -0,0 +1,308 @@
import { useCallback, useMemo } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import { useUIStore, CropAspectRatio } from '../../../stores/ui-store';
import type { ImageLayer } from '../../../types/project';
import { Crop, Check, X, RotateCcw, Lock, Unlock } from 'lucide-react';
const imageCache = new Map<string, HTMLImageElement>();
function getCachedImage(src: string): HTMLImageElement | null {
if (!src) return null;
if (imageCache.has(src)) return imageCache.get(src)!;
const img = new Image();
img.src = src;
imageCache.set(src, img);
return img;
}
interface Props {
layer: ImageLayer;
}
const ASPECT_RATIOS: { value: CropAspectRatio; label: string; ratio?: number }[] = [
{ value: 'free', label: 'Free' },
{ value: 'original', label: 'Original' },
{ value: '1:1', label: '1:1', ratio: 1 },
{ value: '4:3', label: '4:3', ratio: 4 / 3 },
{ value: '3:4', label: '3:4', ratio: 3 / 4 },
{ value: '16:9', label: '16:9', ratio: 16 / 9 },
{ value: '9:16', label: '9:16', ratio: 9 / 16 },
{ value: '3:2', label: '3:2', ratio: 3 / 2 },
{ value: '2:3', label: '2:3', ratio: 2 / 3 },
];
export function CropSection({ layer }: Props) {
const { updateLayer, project } = useProjectStore();
const { crop, startCrop, cancelCrop, applyCrop, setCropAspectRatio, updateCropRect, setCropLockAspect } = useUIStore();
const lockAspect = crop.lockAspect;
const setLockAspect = setCropLockAspect;
const isCropping = crop.isActive && crop.layerId === layer.id;
const imageDimensions = useMemo(() => {
if (!project) return null;
const asset = project.assets[layer.sourceId];
if (!asset) return null;
const src = asset.blobUrl ?? asset.dataUrl;
if (!src) return null;
const img = getCachedImage(src);
if (img && img.complete && img.naturalWidth > 0) {
return { width: img.naturalWidth, height: img.naturalHeight };
}
return asset.width && asset.height ? { width: asset.width, height: asset.height } : null;
}, [project, layer.sourceId]);
const handleStartCrop = useCallback(() => {
const initialRect = layer.cropRect ?? {
x: 0,
y: 0,
width: layer.transform.width,
height: layer.transform.height,
};
startCrop(layer.id, initialRect);
}, [layer, startCrop]);
const handleApplyCrop = useCallback(() => {
const result = applyCrop();
if (result && result.cropRect) {
const existingCropRect = layer.cropRect;
let finalCropRect: { x: number; y: number; width: number; height: number };
if (existingCropRect) {
const scaleX = existingCropRect.width / layer.transform.width;
const scaleY = existingCropRect.height / layer.transform.height;
finalCropRect = {
x: existingCropRect.x + result.cropRect.x * scaleX,
y: existingCropRect.y + result.cropRect.y * scaleY,
width: result.cropRect.width * scaleX,
height: result.cropRect.height * scaleY,
};
} else if (imageDimensions) {
const scaleX = imageDimensions.width / layer.transform.width;
const scaleY = imageDimensions.height / layer.transform.height;
finalCropRect = {
x: result.cropRect.x * scaleX,
y: result.cropRect.y * scaleY,
width: result.cropRect.width * scaleX,
height: result.cropRect.height * scaleY,
};
} else {
finalCropRect = result.cropRect;
}
updateLayer<ImageLayer>(result.layerId, {
cropRect: finalCropRect,
transform: {
...layer.transform,
x: layer.transform.x + result.cropRect.x,
y: layer.transform.y + result.cropRect.y,
width: result.cropRect.width,
height: result.cropRect.height,
},
});
}
}, [applyCrop, updateLayer, layer, imageDimensions]);
const handleResetCrop = useCallback(() => {
if (isCropping) {
updateCropRect({
x: 0,
y: 0,
width: layer.transform.width,
height: layer.transform.height,
});
} else {
updateLayer<ImageLayer>(layer.id, { cropRect: null });
}
}, [isCropping, layer, updateCropRect, updateLayer]);
const handleAspectRatioChange = useCallback(
(ratio: CropAspectRatio) => {
setCropAspectRatio(ratio);
if (!crop.cropRect) return;
const aspectConfig = ASPECT_RATIOS.find((r) => r.value === ratio);
if (!aspectConfig?.ratio) return;
const currentWidth = crop.cropRect.width;
const currentHeight = crop.cropRect.height;
const currentCenterX = crop.cropRect.x + currentWidth / 2;
const currentCenterY = crop.cropRect.y + currentHeight / 2;
let newWidth = currentWidth;
let newHeight = currentWidth / aspectConfig.ratio;
if (newHeight > layer.transform.height) {
newHeight = layer.transform.height;
newWidth = newHeight * aspectConfig.ratio;
}
if (newWidth > layer.transform.width) {
newWidth = layer.transform.width;
newHeight = newWidth / aspectConfig.ratio;
}
let newX = currentCenterX - newWidth / 2;
let newY = currentCenterY - newHeight / 2;
newX = Math.max(0, Math.min(newX, layer.transform.width - newWidth));
newY = Math.max(0, Math.min(newY, layer.transform.height - newHeight));
updateCropRect({
x: Math.round(newX),
y: Math.round(newY),
width: Math.round(newWidth),
height: Math.round(newHeight),
});
},
[crop.cropRect, layer.transform, setCropAspectRatio, updateCropRect]
);
const hasCrop = layer.cropRect !== null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Crop</h4>
{hasCrop && !isCropping && (
<button
onClick={handleResetCrop}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{!isCropping ? (
<button
onClick={handleStartCrop}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium text-sm hover:bg-primary/90 transition-colors"
>
<Crop size={16} />
{hasCrop ? 'Adjust Crop' : 'Crop Image'}
</button>
) : (
<div className="space-y-3 p-3 bg-secondary/30 rounded-lg border border-border/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[11px] font-medium text-foreground">Aspect Ratio</label>
<button
onClick={() => setLockAspect(!lockAspect)}
className={`p-1 rounded transition-colors ${
lockAspect ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
>
{lockAspect ? <Lock size={12} /> : <Unlock size={12} />}
</button>
</div>
<div className="grid grid-cols-3 gap-1">
{ASPECT_RATIOS.map((ar) => (
<button
key={ar.value}
onClick={() => handleAspectRatioChange(ar.value)}
className={`px-2 py-1.5 text-[10px] font-medium rounded transition-colors ${
crop.aspectRatio === ar.value
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/80'
}`}
>
{ar.label}
</button>
))}
</div>
</div>
{crop.cropRect && (
<div className="space-y-2">
<label className="text-[11px] font-medium text-foreground">Crop Area</label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">X</label>
<input
type="number"
value={Math.round(crop.cropRect.x)}
onChange={(e) =>
updateCropRect({
...crop.cropRect!,
x: Math.max(0, Number(e.target.value)),
})
}
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Y</label>
<input
type="number"
value={Math.round(crop.cropRect.y)}
onChange={(e) =>
updateCropRect({
...crop.cropRect!,
y: Math.max(0, Number(e.target.value)),
})
}
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Width</label>
<input
type="number"
value={Math.round(crop.cropRect.width)}
onChange={(e) =>
updateCropRect({
...crop.cropRect!,
width: Math.max(1, Number(e.target.value)),
})
}
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Height</label>
<input
type="number"
value={Math.round(crop.cropRect.height)}
onChange={(e) =>
updateCropRect({
...crop.cropRect!,
height: Math.max(1, Number(e.target.value)),
})
}
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
</div>
)}
<div className="flex gap-2 pt-2">
<button
onClick={handleResetCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-secondary text-foreground rounded-lg font-medium text-[11px] hover:bg-secondary/80 transition-colors"
>
<RotateCcw size={12} />
Reset
</button>
<button
onClick={cancelCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-secondary text-foreground rounded-lg font-medium text-[11px] hover:bg-secondary/80 transition-colors"
>
<X size={12} />
Cancel
</button>
<button
onClick={handleApplyCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg font-medium text-[11px] hover:bg-primary/90 transition-colors"
>
<Check size={12} />
Apply
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,267 @@
import { useState, useRef, useCallback } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { CurvePoint } from '../../../types/adjustments';
import { DEFAULT_CURVES } from '../../../types/adjustments';
import { TrendingUp, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type ChannelType = 'master' | 'red' | 'green' | 'blue';
interface CurveEditorProps {
points: CurvePoint[];
onChange: (points: CurvePoint[]) => void;
channel: ChannelType;
}
function CurveEditor({ points, onChange, channel }: CurveEditorProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const channelColors: Record<ChannelType, string> = {
master: 'hsl(var(--foreground))',
red: '#ef4444',
green: '#22c55e',
blue: '#3b82f6',
};
const sortedPoints = [...points].sort((a, b) => a.input - b.input);
const getPathD = useCallback(() => {
if (sortedPoints.length < 2) return '';
const pathPoints = sortedPoints.map((p) => ({
x: (p.input / 255) * 100,
y: 100 - (p.output / 255) * 100,
}));
let d = `M ${pathPoints[0].x} ${pathPoints[0].y}`;
for (let i = 1; i < pathPoints.length; i++) {
const prev = pathPoints[i - 1];
const curr = pathPoints[i];
const cpx = (prev.x + curr.x) / 2;
d += ` C ${cpx} ${prev.y}, ${cpx} ${curr.y}, ${curr.x} ${curr.y}`;
}
return d;
}, [sortedPoints]);
const handleMouseDown = (index: number, e: React.MouseEvent) => {
e.preventDefault();
if (index === 0 || index === sortedPoints.length - 1) return;
setDraggingIndex(index);
};
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (draggingIndex === null || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 255;
const y = (1 - (e.clientY - rect.top) / rect.height) * 255;
const newPoints = [...sortedPoints];
newPoints[draggingIndex] = {
input: Math.max(1, Math.min(254, Math.round(x))),
output: Math.max(0, Math.min(255, Math.round(y))),
};
onChange(newPoints);
},
[draggingIndex, sortedPoints, onChange]
);
const handleMouseUp = () => {
setDraggingIndex(null);
};
const handleClick = (e: React.MouseEvent) => {
if (draggingIndex !== null || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 255;
const y = (1 - (e.clientY - rect.top) / rect.height) * 255;
if (sortedPoints.length >= 14) return;
const newPoint: CurvePoint = {
input: Math.round(x),
output: Math.round(y),
};
onChange([...sortedPoints, newPoint]);
};
const handleDoubleClick = (index: number, e: React.MouseEvent) => {
e.stopPropagation();
if (index === 0 || index === sortedPoints.length - 1) return;
const newPoints = sortedPoints.filter((_, i) => i !== index);
onChange(newPoints);
};
return (
<div className="relative">
<svg
ref={svgRef}
viewBox="0 0 100 100"
className="w-full h-32 bg-secondary/50 rounded border border-border cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onClick={handleClick}
>
<defs>
<pattern id="grid" width="25" height="25" patternUnits="userSpaceOnUse">
<path d="M 25 0 L 0 0 0 25" fill="none" stroke="hsl(var(--border))" strokeWidth="0.5" opacity="0.5" />
</pattern>
</defs>
<rect width="100" height="100" fill="url(#grid)" />
<line x1="0" y1="100" x2="100" y2="0" stroke="hsl(var(--muted-foreground))" strokeWidth="0.5" strokeDasharray="2 2" opacity="0.3" />
<path d={getPathD()} fill="none" stroke={channelColors[channel]} strokeWidth="2" />
{sortedPoints.map((point, index) => {
const x = (point.input / 255) * 100;
const y = 100 - (point.output / 255) * 100;
const isEndpoint = index === 0 || index === sortedPoints.length - 1;
const isHovered = hoverIndex === index;
const isDragging = draggingIndex === index;
return (
<circle
key={index}
cx={x}
cy={y}
r={isDragging || isHovered ? 4 : 3}
fill={isEndpoint ? 'hsl(var(--muted-foreground))' : channelColors[channel]}
stroke="hsl(var(--background))"
strokeWidth="1"
className={isEndpoint ? 'cursor-not-allowed' : 'cursor-move'}
onMouseDown={(e) => handleMouseDown(index, e)}
onDoubleClick={(e) => handleDoubleClick(index, e)}
onMouseEnter={() => setHoverIndex(index)}
onMouseLeave={() => setHoverIndex(null)}
/>
);
})}
</svg>
<div className="flex justify-between mt-1 text-[9px] text-muted-foreground">
<span>0</span>
<span>Input</span>
<span>255</span>
</div>
</div>
);
}
export function CurvesSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeChannel, setActiveChannel] = useState<ChannelType>('master');
const [isExpanded, setIsExpanded] = useState(true);
const curves = layer.curves;
const handlePointsChange = (points: CurvePoint[]) => {
updateLayer(layer.id, {
curves: {
...curves,
[activeChannel]: { points },
},
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
curves: {
...curves,
enabled,
},
});
};
const resetCurves = () => {
updateLayer(layer.id, {
curves: { ...DEFAULT_CURVES },
});
};
const channelColors: Record<ChannelType, string> = {
master: 'bg-foreground',
red: 'bg-red-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
};
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<TrendingUp size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Curves</span>
{curves.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<div className="flex items-center gap-1">
<input
type="checkbox"
checked={curves.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{(['master', 'red', 'green', 'blue'] as ChannelType[]).map((channel) => (
<button
key={channel}
onClick={() => setActiveChannel(channel)}
className={`px-2 py-1 text-[10px] rounded transition-colors ${
activeChannel === channel
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${channelColors[channel]}`} />
{channel.charAt(0).toUpperCase()}
</button>
))}
</div>
<button
onClick={resetCurves}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset Curves"
>
<RotateCcw size={12} />
</button>
</div>
<CurveEditor
points={curves[activeChannel].points}
onChange={handlePointsChange}
channel={activeChannel}
/>
<p className="text-[9px] text-muted-foreground text-center">
Click to add point Double-click to remove Drag to adjust
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,167 @@
import { useUIStore } from '../../../stores/ui-store';
import { Sun, Moon } from 'lucide-react';
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 0 : 0)}{unit}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
}}
/>
</div>
);
}
export function DodgeBurnToolPanel() {
const { activeTool, dodgeBurnSettings, setDodgeBurnSettings } = useUIStore();
if (activeTool !== 'dodge' && activeTool !== 'burn') {
return null;
}
const toolTypes = [
{ id: 'dodge' as const, icon: Sun, label: 'Dodge' },
{ id: 'burn' as const, icon: Moon, label: 'Burn' },
];
const ranges = [
{ id: 'shadows' as const, label: 'Shadows' },
{ id: 'midtones' as const, label: 'Midtones' },
{ id: 'highlights' as const, label: 'Highlights' },
];
return (
<div className="border-b border-border">
<div className="px-3 py-2 flex items-center gap-2">
{dodgeBurnSettings.type === 'dodge' ? (
<Sun size={14} className="text-muted-foreground" />
) : (
<Moon size={14} className="text-muted-foreground" />
)}
<span className="text-xs font-medium">
{dodgeBurnSettings.type === 'dodge' ? 'Dodge Tool' : 'Burn Tool'}
</span>
</div>
<div className="px-3 pb-3 space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tool</span>
<div className="flex gap-1">
{toolTypes.map((type) => (
<button
key={type.id}
onClick={() => setDodgeBurnSettings({ type: type.id })}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded transition-colors ${
dodgeBurnSettings.type === type.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
<type.icon size={12} />
{type.label}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Range</span>
<div className="flex gap-1">
{ranges.map((range) => (
<button
key={range.id}
onClick={() => setDodgeBurnSettings({ range: range.id })}
className={`flex-1 px-2 py-1.5 text-[10px] rounded transition-colors ${
dodgeBurnSettings.range === range.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
{range.label}
</button>
))}
</div>
</div>
<Slider
label="Exposure"
value={dodgeBurnSettings.exposure}
min={1}
max={100}
unit="%"
onChange={(v) => setDodgeBurnSettings({ exposure: v })}
/>
<Slider
label="Size"
value={dodgeBurnSettings.size}
min={1}
max={500}
unit="px"
onChange={(v) => setDodgeBurnSettings({ size: v })}
/>
<div className="pt-2 border-t border-border">
<div className="flex items-center justify-center p-3 bg-secondary/30 rounded-lg">
<div
className="rounded-full transition-all"
style={{
width: Math.min(dodgeBurnSettings.size, 80),
height: Math.min(dodgeBurnSettings.size, 80),
background:
dodgeBurnSettings.type === 'dodge'
? `radial-gradient(circle, rgba(255,255,255,${dodgeBurnSettings.exposure / 100}) 0%, transparent 70%)`
: `radial-gradient(circle, rgba(0,0,0,${dodgeBurnSettings.exposure / 100}) 0%, transparent 70%)`,
}}
/>
</div>
<p className="text-[9px] text-muted-foreground text-center mt-1.5">
{dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} {dodgeBurnSettings.range}
</p>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tips</span>
<ul className="text-[9px] text-muted-foreground space-y-0.5">
<li> {dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} selected tonal range</li>
<li> Lower exposure for subtle adjustments</li>
<li> Build up effect with multiple strokes</li>
</ul>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,379 @@
import { useProjectStore } from '../../../stores/project-store';
import type { Layer, Shadow, InnerShadow, Stroke, Glow } from '../../../types/project';
import { Slider } from '@openreel/ui';
import { ChevronDown, Droplets, Pencil, Sparkles, CircleDot } from 'lucide-react';
import { useState } from 'react';
interface Props {
layer: Layer;
}
type EffectSection = 'shadow' | 'innerShadow' | 'stroke' | 'glow' | null;
interface EffectHeaderProps {
icon: React.ElementType;
label: string;
enabled: boolean;
isOpen: boolean;
onToggle: () => void;
onEnabledChange: (enabled: boolean) => void;
}
function EffectHeader({ icon: Icon, label, enabled, isOpen, onToggle, onEnabledChange }: EffectHeaderProps) {
return (
<div className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
<button
onClick={onToggle}
className="flex items-center gap-2 flex-1 text-left"
>
<Icon size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">{label}</span>
</button>
<div className="flex items-center gap-2">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => onEnabledChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-8 h-4 bg-muted rounded-full peer peer-checked:bg-primary transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:after:translate-x-4" />
</label>
<button onClick={onToggle} className="p-0.5">
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
</div>
</div>
);
}
export function EffectsSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [openSection, setOpenSection] = useState<EffectSection>('shadow');
const handleShadowChange = (updates: Partial<Shadow>) => {
updateLayer(layer.id, {
shadow: { ...layer.shadow, ...updates },
});
};
const handleInnerShadowChange = (updates: Partial<InnerShadow>) => {
updateLayer(layer.id, {
innerShadow: { ...(layer.innerShadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 2, offsetY: 2 }), ...updates },
});
};
const handleStrokeChange = (updates: Partial<Stroke>) => {
updateLayer(layer.id, {
stroke: { ...layer.stroke, ...updates },
});
};
const handleGlowChange = (updates: Partial<Glow>) => {
updateLayer(layer.id, {
glow: { ...layer.glow, ...updates },
});
};
const toggleSection = (section: EffectSection) => {
setOpenSection(openSection === section ? null : section);
};
return (
<div className="px-4 space-y-2">
<div>
<EffectHeader
icon={Droplets}
label="Drop Shadow"
enabled={layer.shadow.enabled}
isOpen={openSection === 'shadow'}
onToggle={() => toggleSection('shadow')}
onEnabledChange={(enabled) => handleShadowChange({ enabled })}
/>
{openSection === 'shadow' && (
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.shadow.color.startsWith('rgba') ? '#000000' : layer.shadow.color}
onChange={(e) => handleShadowChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
disabled={!layer.shadow.enabled}
/>
<input
type="text"
value={layer.shadow.color}
onChange={(e) => handleShadowChange({ color: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50"
disabled={!layer.shadow.enabled}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur</label>
<span className="text-[10px] text-muted-foreground">{layer.shadow.blur}px</span>
</div>
<Slider
value={[layer.shadow.blur]}
onValueChange={([blur]) => handleShadowChange({ blur })}
min={0}
max={100}
step={1}
disabled={!layer.shadow.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset X</label>
<span className="text-[10px] text-muted-foreground">{layer.shadow.offsetX}px</span>
</div>
<Slider
value={[layer.shadow.offsetX]}
onValueChange={([offsetX]) => handleShadowChange({ offsetX })}
min={-50}
max={50}
step={1}
disabled={!layer.shadow.enabled}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset Y</label>
<span className="text-[10px] text-muted-foreground">{layer.shadow.offsetY}px</span>
</div>
<Slider
value={[layer.shadow.offsetY]}
onValueChange={([offsetY]) => handleShadowChange({ offsetY })}
min={-50}
max={50}
step={1}
disabled={!layer.shadow.enabled}
/>
</div>
</div>
</div>
)}
</div>
<div>
<EffectHeader
icon={CircleDot}
label="Inner Shadow"
enabled={layer.innerShadow?.enabled ?? false}
isOpen={openSection === 'innerShadow'}
onToggle={() => toggleSection('innerShadow')}
onEnabledChange={(enabled) => handleInnerShadowChange({ enabled })}
/>
{openSection === 'innerShadow' && (
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={(layer.innerShadow?.color ?? 'rgba(0, 0, 0, 0.5)').startsWith('rgba') ? '#000000' : layer.innerShadow?.color ?? '#000000'}
onChange={(e) => handleInnerShadowChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
disabled={!layer.innerShadow?.enabled}
/>
<input
type="text"
value={layer.innerShadow?.color ?? 'rgba(0, 0, 0, 0.5)'}
onChange={(e) => handleInnerShadowChange({ color: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50"
disabled={!layer.innerShadow?.enabled}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur</label>
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.blur ?? 10}px</span>
</div>
<Slider
value={[layer.innerShadow?.blur ?? 10]}
onValueChange={([blur]) => handleInnerShadowChange({ blur })}
min={0}
max={50}
step={1}
disabled={!layer.innerShadow?.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset X</label>
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.offsetX ?? 2}px</span>
</div>
<Slider
value={[layer.innerShadow?.offsetX ?? 2]}
onValueChange={([offsetX]) => handleInnerShadowChange({ offsetX })}
min={-30}
max={30}
step={1}
disabled={!layer.innerShadow?.enabled}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset Y</label>
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.offsetY ?? 2}px</span>
</div>
<Slider
value={[layer.innerShadow?.offsetY ?? 2]}
onValueChange={([offsetY]) => handleInnerShadowChange({ offsetY })}
min={-30}
max={30}
step={1}
disabled={!layer.innerShadow?.enabled}
/>
</div>
</div>
</div>
)}
</div>
<div>
<EffectHeader
icon={Pencil}
label="Stroke"
enabled={layer.stroke.enabled}
isOpen={openSection === 'stroke'}
onToggle={() => toggleSection('stroke')}
onEnabledChange={(enabled) => handleStrokeChange({ enabled })}
/>
{openSection === 'stroke' && (
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.stroke.color}
onChange={(e) => handleStrokeChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
disabled={!layer.stroke.enabled}
/>
<input
type="text"
value={layer.stroke.color}
onChange={(e) => handleStrokeChange({ color: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50"
disabled={!layer.stroke.enabled}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Width</label>
<span className="text-[10px] text-muted-foreground">{layer.stroke.width}px</span>
</div>
<Slider
value={[layer.stroke.width]}
onValueChange={([width]) => handleStrokeChange({ width })}
min={1}
max={20}
step={1}
disabled={!layer.stroke.enabled}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Style</label>
<div className="grid grid-cols-3 gap-1">
{(['solid', 'dashed', 'dotted'] as const).map((style) => (
<button
key={style}
onClick={() => handleStrokeChange({ style })}
disabled={!layer.stroke.enabled}
className={`px-2 py-1.5 text-[10px] rounded capitalize transition-colors ${
layer.stroke.style === style
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent disabled:opacity-50'
}`}
>
{style}
</button>
))}
</div>
</div>
</div>
)}
</div>
<div>
<EffectHeader
icon={Sparkles}
label="Outer Glow"
enabled={layer.glow.enabled}
isOpen={openSection === 'glow'}
onToggle={() => toggleSection('glow')}
onEnabledChange={(enabled) => handleGlowChange({ enabled })}
/>
{openSection === 'glow' && (
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.glow.color}
onChange={(e) => handleGlowChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
disabled={!layer.glow.enabled}
/>
<input
type="text"
value={layer.glow.color}
onChange={(e) => handleGlowChange({ color: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50"
disabled={!layer.glow.enabled}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur</label>
<span className="text-[10px] text-muted-foreground">{layer.glow.blur}px</span>
</div>
<Slider
value={[layer.glow.blur]}
onValueChange={([blur]) => handleGlowChange({ blur })}
min={0}
max={100}
step={1}
disabled={!layer.glow.enabled}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Intensity</label>
<span className="text-[10px] text-muted-foreground">{Math.round(layer.glow.intensity * 100)}%</span>
</div>
<Slider
value={[layer.glow.intensity]}
onValueChange={([intensity]) => handleGlowChange({ intensity })}
min={0}
max={2}
step={0.1}
disabled={!layer.glow.enabled}
/>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,153 @@
import { useUIStore } from '../../../stores/ui-store';
import { Eraser, Square, Pencil, Circle } from 'lucide-react';
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 0 : 0)}{unit}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
}}
/>
</div>
);
}
export function EraserToolPanel() {
const { activeTool, eraserSettings, setEraserSettings } = useUIStore();
if (activeTool !== 'eraser') {
return null;
}
const eraserModes = [
{ id: 'brush' as const, icon: Circle, label: 'Brush' },
{ id: 'pencil' as const, icon: Pencil, label: 'Pencil' },
{ id: 'block' as const, icon: Square, label: 'Block' },
];
return (
<div className="border-b border-border">
<div className="px-3 py-2 flex items-center gap-2">
<Eraser size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Eraser Tool</span>
</div>
<div className="px-3 pb-3 space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Mode</span>
<div className="flex gap-1">
{eraserModes.map((mode) => (
<button
key={mode.id}
onClick={() => setEraserSettings({ mode: mode.id })}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded transition-colors ${
eraserSettings.mode === mode.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
<mode.icon size={12} />
{mode.label}
</button>
))}
</div>
</div>
<Slider
label="Size"
value={eraserSettings.size}
min={1}
max={500}
unit="px"
onChange={(v) => setEraserSettings({ size: v })}
/>
<Slider
label="Hardness"
value={eraserSettings.hardness}
min={0}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ hardness: v })}
/>
<Slider
label="Opacity"
value={Math.round(eraserSettings.opacity * 100)}
min={1}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ opacity: v / 100 })}
/>
<Slider
label="Flow"
value={Math.round(eraserSettings.flow * 100)}
min={1}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ flow: v / 100 })}
/>
<div className="pt-2 border-t border-border">
<div className="flex items-center justify-center p-3 bg-secondary/30 rounded-lg">
<div
className="rounded-full bg-foreground transition-all"
style={{
width: Math.min(eraserSettings.size, 100),
height: Math.min(eraserSettings.size, 100),
opacity: eraserSettings.opacity,
filter: `blur(${(100 - eraserSettings.hardness) / 20}px)`,
}}
/>
</div>
<p className="text-[9px] text-muted-foreground text-center mt-1.5">
Brush preview
</p>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tips</span>
<ul className="text-[9px] text-muted-foreground space-y-0.5">
<li> Hold Shift for straight lines</li>
<li> [ and ] to adjust size</li>
<li> Shift+[ and ] for hardness</li>
</ul>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,287 @@
import { useState, useMemo } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { ImageLayer, Filter } from '../../../types/project';
import { Sparkles, Check } from 'lucide-react';
interface Props {
layer: ImageLayer;
}
interface FilterPreset {
id: string;
name: string;
category: 'basic' | 'vintage' | 'cinematic' | 'mood';
filters: Filter;
thumbnail?: string;
}
const FILTER_PRESETS: FilterPreset[] = [
{
id: 'original',
name: 'Original',
category: 'basic',
filters: { brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'vivid',
name: 'Vivid',
category: 'basic',
filters: { brightness: 105, contrast: 115, saturation: 130, hue: 0, exposure: 0, vibrance: 30, highlights: 0, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'warm',
name: 'Warm',
category: 'mood',
filters: { brightness: 105, contrast: 105, saturation: 110, hue: 15, exposure: 5, vibrance: 15, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'cool',
name: 'Cool',
category: 'mood',
filters: { brightness: 100, contrast: 105, saturation: 95, hue: -15, exposure: 0, vibrance: 0, highlights: 5, shadows: 0, clarity: 5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'bw',
name: 'B&W',
category: 'basic',
filters: { brightness: 105, contrast: 115, saturation: 0, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 20, grain: 5, sepia: 0, invert: 0 },
},
{
id: 'vintage',
name: 'Vintage',
category: 'vintage',
filters: { brightness: 95, contrast: 90, saturation: 75, hue: 20, exposure: -5, vibrance: -10, highlights: -10, shadows: 15, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 30, grain: 15, sepia: 20, invert: 0 },
},
{
id: 'fade',
name: 'Fade',
category: 'vintage',
filters: { brightness: 110, contrast: 85, saturation: 80, hue: 0, exposure: 5, vibrance: -5, highlights: 10, shadows: 20, clarity: -10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 15, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'dramatic',
name: 'Dramatic',
category: 'cinematic',
filters: { brightness: 95, contrast: 130, saturation: 90, hue: 0, exposure: -5, vibrance: 10, highlights: -15, shadows: -10, clarity: 25, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 15, vignette: 25, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'moody',
name: 'Moody',
category: 'mood',
filters: { brightness: 90, contrast: 110, saturation: 85, hue: -10, exposure: -10, vibrance: 0, highlights: -20, shadows: 5, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 35, grain: 5, sepia: 0, invert: 0 },
},
{
id: 'bright',
name: 'Bright',
category: 'basic',
filters: { brightness: 120, contrast: 105, saturation: 105, hue: 0, exposure: 15, vibrance: 10, highlights: 10, shadows: 20, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'sepia',
name: 'Sepia',
category: 'vintage',
filters: { brightness: 105, contrast: 95, saturation: 40, hue: 35, exposure: 0, vibrance: -20, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 20, grain: 10, sepia: 50, invert: 0 },
},
{
id: 'cinematic',
name: 'Cinematic',
category: 'cinematic',
filters: { brightness: 95, contrast: 115, saturation: 95, hue: -5, exposure: 0, vibrance: 5, highlights: -10, shadows: 5, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 20, grain: 3, sepia: 0, invert: 0 },
},
{
id: 'pop',
name: 'Pop',
category: 'mood',
filters: { brightness: 110, contrast: 120, saturation: 140, hue: 5, exposure: 5, vibrance: 40, highlights: 5, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'matte',
name: 'Matte',
category: 'cinematic',
filters: { brightness: 105, contrast: 85, saturation: 90, hue: 0, exposure: 0, vibrance: -5, highlights: 5, shadows: 15, clarity: -5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 10, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'retro',
name: 'Retro',
category: 'vintage',
filters: { brightness: 100, contrast: 95, saturation: 70, hue: 25, exposure: -5, vibrance: -15, highlights: -5, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 25, grain: 20, sepia: 15, invert: 0 },
},
{
id: 'punch',
name: 'Punch',
category: 'basic',
filters: { brightness: 100, contrast: 125, saturation: 115, hue: 0, exposure: 0, vibrance: 20, highlights: 0, shadows: -10, clarity: 20, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 20, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
];
function filtersMatch(a: Filter, b: Filter): boolean {
return (
a.brightness === b.brightness &&
a.contrast === b.contrast &&
a.saturation === b.saturation &&
a.hue === b.hue &&
a.exposure === b.exposure &&
a.vibrance === b.vibrance &&
a.highlights === b.highlights &&
a.shadows === b.shadows &&
a.clarity === b.clarity &&
a.blur === b.blur &&
a.blurType === b.blurType &&
a.blurAngle === b.blurAngle &&
a.sharpen === b.sharpen &&
a.vignette === b.vignette &&
a.grain === b.grain &&
a.sepia === b.sepia &&
a.invert === b.invert
);
}
function interpolateFilters(target: Filter, intensity: number): Filter {
const lerp = (defaultVal: number, targetVal: number) => defaultVal + (targetVal - defaultVal) * (intensity / 100);
return {
brightness: Math.round(lerp(100, target.brightness)),
contrast: Math.round(lerp(100, target.contrast)),
saturation: Math.round(lerp(100, target.saturation)),
hue: Math.round(lerp(0, target.hue)),
exposure: Math.round(lerp(0, target.exposure)),
vibrance: Math.round(lerp(0, target.vibrance)),
highlights: Math.round(lerp(0, target.highlights)),
shadows: Math.round(lerp(0, target.shadows)),
clarity: Math.round(lerp(0, target.clarity)),
blur: Math.round(lerp(0, target.blur)),
blurType: target.blurType,
blurAngle: Math.round(lerp(0, target.blurAngle)),
sharpen: Math.round(lerp(0, target.sharpen)),
vignette: Math.round(lerp(0, target.vignette)),
grain: Math.round(lerp(0, target.grain)),
sepia: Math.round(lerp(0, target.sepia)),
invert: Math.round(lerp(0, target.invert)),
};
}
export function FilterPresetsSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [intensity, setIntensity] = useState(100);
const [activePresetId, setActivePresetId] = useState<string | null>(() => {
const match = FILTER_PRESETS.find((p) => filtersMatch(layer.filters, p.filters));
return match?.id ?? null;
});
const currentPreset = useMemo(
() => FILTER_PRESETS.find((p) => p.id === activePresetId),
[activePresetId]
);
const handlePresetSelect = (preset: FilterPreset) => {
setActivePresetId(preset.id);
const filters = intensity === 100 ? preset.filters : interpolateFilters(preset.filters, intensity);
updateLayer<ImageLayer>(layer.id, { filters });
};
const handleIntensityChange = (newIntensity: number) => {
setIntensity(newIntensity);
if (currentPreset) {
const filters = interpolateFilters(currentPreset.filters, newIntensity);
updateLayer<ImageLayer>(layer.id, { filters });
}
};
const isOriginal = activePresetId === 'original' || filtersMatch(layer.filters, FILTER_PRESETS[0].filters);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Filters
</h4>
{!isOriginal && (
<button
onClick={() => handlePresetSelect(FILTER_PRESETS[0])}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{activePresetId && activePresetId !== 'original' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[11px] font-medium text-foreground">Intensity</label>
<span className="text-[11px] font-mono text-muted-foreground">{intensity}%</span>
</div>
<input
type="range"
value={intensity}
min={0}
max={100}
onChange={(e) => handleIntensityChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:cursor-pointer"
/>
</div>
)}
<div className="grid grid-cols-4 gap-2">
{FILTER_PRESETS.map((preset) => {
const isActive = activePresetId === preset.id;
const previewStyle = getFilterPreviewStyle(preset.filters);
return (
<button
key={preset.id}
onClick={() => handlePresetSelect(preset)}
className={`relative group flex flex-col items-center gap-1 p-2 rounded-lg transition-all ${
isActive
? 'bg-primary/20 ring-2 ring-primary'
: 'bg-secondary/50 hover:bg-secondary'
}`}
>
<div
className="w-10 h-10 rounded-md bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center overflow-hidden"
style={previewStyle}
>
{preset.id === 'original' ? (
<Sparkles size={16} className="text-white/80" />
) : isActive ? (
<Check size={14} className="text-primary" />
) : null}
</div>
<span className={`text-[9px] font-medium truncate w-full text-center ${
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
}`}>
{preset.name}
</span>
</button>
);
})}
</div>
</div>
);
}
function getFilterPreviewStyle(filters: Filter): React.CSSProperties {
const filterParts: string[] = [];
if (filters.brightness !== 100) {
filterParts.push(`brightness(${filters.brightness}%)`);
}
if (filters.contrast !== 100) {
filterParts.push(`contrast(${filters.contrast}%)`);
}
if (filters.saturation !== 100) {
filterParts.push(`saturate(${filters.saturation}%)`);
}
if (filters.hue !== 0) {
filterParts.push(`hue-rotate(${filters.hue}deg)`);
}
return {
filter: filterParts.length > 0 ? filterParts.join(' ') : undefined,
};
}

View file

@ -0,0 +1,202 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { GradientMapStop } from '../../../types/adjustments';
import { DEFAULT_GRADIENT_MAP } from '../../../types/adjustments';
import { Paintbrush, RotateCcw, Plus, X } from 'lucide-react';
interface Props {
layer: Layer;
}
const GRADIENT_PRESETS = [
{ name: 'B&W', stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }] },
{ name: 'Sepia', stops: [{ position: 0, color: '#2b1810' }, { position: 0.5, color: '#8b5a2b' }, { position: 1, color: '#f5deb3' }] },
{ name: 'Duotone Blue', stops: [{ position: 0, color: '#001133' }, { position: 1, color: '#66ccff' }] },
{ name: 'Duotone Orange', stops: [{ position: 0, color: '#331100' }, { position: 1, color: '#ff9900' }] },
{ name: 'Sunset', stops: [{ position: 0, color: '#1a0533' }, { position: 0.5, color: '#ff6b35' }, { position: 1, color: '#f7c59f' }] },
];
export function GradientMapSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const gradientMap = layer.gradientMap;
const handleStopChange = (index: number, updates: Partial<GradientMapStop>) => {
const newStops = [...gradientMap.stops];
newStops[index] = { ...newStops[index], ...updates };
updateLayer(layer.id, {
gradientMap: { ...gradientMap, stops: newStops },
});
};
const addStop = () => {
const newStops = [...gradientMap.stops, { position: 0.5, color: '#808080' }];
newStops.sort((a, b) => a.position - b.position);
updateLayer(layer.id, {
gradientMap: { ...gradientMap, stops: newStops },
});
};
const removeStop = (index: number) => {
if (gradientMap.stops.length <= 2) return;
const newStops = gradientMap.stops.filter((_, i) => i !== index);
updateLayer(layer.id, {
gradientMap: { ...gradientMap, stops: newStops },
});
};
const handleReverseChange = (reverse: boolean) => {
updateLayer(layer.id, {
gradientMap: { ...gradientMap, reverse },
});
};
const handleDitherChange = (dither: boolean) => {
updateLayer(layer.id, {
gradientMap: { ...gradientMap, dither },
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
gradientMap: { ...gradientMap, enabled },
});
};
const applyPreset = (preset: typeof GRADIENT_PRESETS[0]) => {
updateLayer(layer.id, {
gradientMap: { ...gradientMap, stops: preset.stops },
});
};
const resetGradientMap = () => {
updateLayer(layer.id, {
gradientMap: { ...DEFAULT_GRADIENT_MAP },
});
};
const gradientStyle = `linear-gradient(to right, ${gradientMap.stops
.map((s) => `${s.color} ${s.position * 100}%`)
.join(', ')})`;
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paintbrush size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Gradient Map</span>
{gradientMap.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={gradientMap.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1 flex-wrap">
{GRADIENT_PRESETS.map((preset) => (
<button
key={preset.name}
onClick={() => applyPreset(preset)}
className="px-2 py-1 text-[9px] bg-secondary/50 hover:bg-secondary rounded text-muted-foreground hover:text-foreground transition-colors"
>
{preset.name}
</button>
))}
</div>
<button
onClick={resetGradientMap}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div
className="h-6 rounded border border-border"
style={{ background: gradientStyle }}
/>
<div className="space-y-2">
{gradientMap.stops.map((stop, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="color"
value={stop.color}
onChange={(e) => handleStopChange(index, { color: e.target.value })}
className="w-6 h-6 rounded border-none cursor-pointer"
/>
<input
type="range"
value={stop.position * 100}
min={0}
max={100}
onChange={(e) => handleStopChange(index, { position: Number(e.target.value) / 100 })}
className="flex-1 h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
<span className="text-[10px] font-mono text-muted-foreground w-8">{Math.round(stop.position * 100)}%</span>
{gradientMap.stops.length > 2 && (
<button
onClick={() => removeStop(index)}
className="p-0.5 text-muted-foreground hover:text-destructive rounded hover:bg-secondary transition-colors"
>
<X size={10} />
</button>
)}
</div>
))}
</div>
<button
onClick={addStop}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
<Plus size={10} /> Add Stop
</button>
<div className="flex gap-4 pt-1 border-t border-border/50">
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={gradientMap.reverse}
onChange={(e) => handleReverseChange(e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Reverse
</label>
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={gradientMap.dither}
onChange={(e) => handleDitherChange(e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Dither
</label>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,176 @@
import { useUIStore } from '../../../stores/ui-store';
import { SquareStack, RotateCcw, X, Plus } from 'lucide-react';
const gradientTypes = [
{ id: 'linear', label: 'Linear' },
{ id: 'radial', label: 'Radial' },
{ id: 'angle', label: 'Angle' },
{ id: 'reflected', label: 'Reflected' },
{ id: 'diamond', label: 'Diamond' },
] as const;
export function GradientToolPanel() {
const { gradientSettings, setGradientSettings } = useUIStore();
const resetSettings = () => {
setGradientSettings({
type: 'linear',
colors: ['#000000', '#ffffff'],
opacity: 1,
reverse: false,
dither: true,
});
};
const updateColor = (index: number, color: string) => {
const newColors = [...gradientSettings.colors];
newColors[index] = color;
setGradientSettings({ colors: newColors });
};
const addColor = () => {
if (gradientSettings.colors.length >= 5) return;
const newColors = [...gradientSettings.colors, '#808080'];
setGradientSettings({ colors: newColors });
};
const removeColor = (index: number) => {
if (gradientSettings.colors.length <= 2) return;
const newColors = gradientSettings.colors.filter((_, i) => i !== index);
setGradientSettings({ colors: newColors });
};
const gradientStyle = `linear-gradient(to right, ${gradientSettings.colors.join(', ')})`;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SquareStack size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Gradient</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Click and drag on canvas to create gradient.
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Type</span>
<div className="grid grid-cols-5 gap-1">
{gradientTypes.map((type) => (
<button
key={type.id}
onClick={() => setGradientSettings({ type: type.id })}
className={`px-1 py-1.5 text-[10px] rounded transition-colors ${
gradientSettings.type === type.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{type.label}
</button>
))}
</div>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Preview</span>
<div
className="h-6 rounded border border-border"
style={{ background: gradientStyle }}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Colors</span>
{gradientSettings.colors.length < 5 && (
<button
onClick={addColor}
className="p-0.5 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
>
<Plus size={12} />
</button>
)}
</div>
<div className="space-y-1.5">
{gradientSettings.colors.map((color, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="color"
value={color}
onChange={(e) => updateColor(index, e.target.value)}
className="w-8 h-8 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={color}
onChange={(e) => updateColor(index, e.target.value)}
className="flex-1 px-2 py-1 text-xs font-mono bg-secondary/50 border border-border rounded"
/>
{gradientSettings.colors.length > 2 && (
<button
onClick={() => removeColor(index)}
className="p-1 text-muted-foreground hover:text-destructive rounded hover:bg-secondary transition-colors"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(gradientSettings.opacity * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={gradientSettings.opacity * 100}
onChange={(e) => setGradientSettings({ opacity: Number(e.target.value) / 100 })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div className="flex gap-4 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={gradientSettings.reverse}
onChange={(e) => setGradientSettings({ reverse: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Reverse
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={gradientSettings.dither}
onChange={(e) => setGradientSettings({ dither: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Dither
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,117 @@
import { useUIStore } from '../../../stores/ui-store';
import { Bandage, RotateCcw } from 'lucide-react';
export function HealingBrushToolPanel() {
const { healingBrushSettings, setHealingBrushSettings } = useUIStore();
const resetSettings = () => {
setHealingBrushSettings({
size: 30,
hardness: 50,
mode: 'normal',
sourcePoint: null,
aligned: true,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bandage size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Healing Brush</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Hold Alt/Option and click to set source, then paint to heal while matching texture and lighting.
</p>
{healingBrushSettings.sourcePoint && (
<div className="text-xs text-muted-foreground bg-secondary/50 p-2 rounded">
Source: ({Math.round(healingBrushSettings.sourcePoint.x)}, {Math.round(healingBrushSettings.sourcePoint.y)})
</div>
)}
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{healingBrushSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={healingBrushSettings.size}
onChange={(e) => setHealingBrushSettings({ size: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Hardness</span>
<span className="text-xs font-mono text-muted-foreground">{healingBrushSettings.hardness}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={healingBrushSettings.hardness}
onChange={(e) => setHealingBrushSettings({ hardness: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
<div className="grid grid-cols-2 gap-1">
{(['normal', 'replace', 'multiply', 'screen'] as const).map((mode) => (
<button
key={mode}
onClick={() => setHealingBrushSettings({ mode })}
className={`px-2 py-1.5 text-xs rounded transition-colors capitalize ${
healingBrushSettings.mode === mode
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{mode}
</button>
))}
</div>
</div>
<div className="pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={healingBrushSettings.aligned}
onChange={(e) => setHealingBrushSettings({ aligned: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Aligned
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,347 @@
import { useProjectStore } from '../../../stores/project-store';
import type { ImageLayer, Filter, BlurType } from '../../../types/project';
import { Sun, Contrast, Palette, Thermometer, Focus, Sparkles, CircleDot, Scan, Film, Minus, Move, Target, SunMedium, Vibrate, Sunrise, SunDim, Aperture } from 'lucide-react';
interface Props {
layer: ImageLayer;
}
interface AdjustmentSliderProps {
icon: React.ReactNode;
label: string;
value: number;
min: number;
max: number;
defaultValue: number;
onChange: (value: number) => void;
unit?: string;
}
function AdjustmentSlider({ icon, label, value, min, max, defaultValue, onChange, unit = '' }: AdjustmentSliderProps) {
const isModified = value !== defaultValue;
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">{icon}</span>
<label className="text-[11px] text-foreground font-medium">{label}</label>
</div>
<div className="flex items-center gap-1">
<span className={`text-[11px] font-mono ${isModified ? 'text-primary' : 'text-muted-foreground'}`}>
{value}{unit}
</span>
{isModified && (
<button
onClick={() => onChange(defaultValue)}
className="text-[9px] text-muted-foreground hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary transition-colors"
>
Reset
</button>
)}
</div>
</div>
<div className="relative">
<input
type="range"
value={value}
min={min}
max={max}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer
[&::-webkit-slider-thumb]:transition-transform
[&::-webkit-slider-thumb]:hover:scale-110"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
}}
/>
</div>
</div>
);
}
export function ImageAdjustmentsSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const handleFilterChange = (key: keyof Filter, value: number | BlurType) => {
updateLayer<ImageLayer>(layer.id, {
filters: { ...layer.filters, [key]: value },
});
};
const handleBlurTypeChange = (type: BlurType) => {
updateLayer<ImageLayer>(layer.id, {
filters: { ...layer.filters, blurType: type },
});
};
const resetAllFilters = () => {
updateLayer<ImageLayer>(layer.id, {
filters: {
brightness: 100,
contrast: 100,
saturation: 100,
hue: 0,
exposure: 0,
vibrance: 0,
highlights: 0,
shadows: 0,
clarity: 0,
blur: 0,
blurType: 'gaussian',
blurAngle: 0,
sharpen: 0,
vignette: 0,
grain: 0,
sepia: 0,
invert: 0,
},
});
};
const hasModifications =
layer.filters.brightness !== 100 ||
layer.filters.contrast !== 100 ||
layer.filters.saturation !== 100 ||
layer.filters.hue !== 0 ||
layer.filters.exposure !== 0 ||
layer.filters.vibrance !== 0 ||
layer.filters.highlights !== 0 ||
layer.filters.shadows !== 0 ||
layer.filters.clarity !== 0 ||
layer.filters.blur !== 0 ||
layer.filters.sharpen !== 0 ||
layer.filters.vignette !== 0 ||
layer.filters.grain !== 0 ||
layer.filters.sepia !== 0 ||
layer.filters.invert !== 0;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Adjustments
</h4>
{hasModifications && (
<button
onClick={resetAllFilters}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset All
</button>
)}
</div>
<div className="space-y-4 p-3 bg-secondary/30 rounded-lg border border-border/50">
<AdjustmentSlider
icon={<Sun size={12} />}
label="Brightness"
value={layer.filters.brightness}
min={0}
max={200}
defaultValue={100}
onChange={(v) => handleFilterChange('brightness', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Contrast size={12} />}
label="Contrast"
value={layer.filters.contrast}
min={0}
max={200}
defaultValue={100}
onChange={(v) => handleFilterChange('contrast', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Palette size={12} />}
label="Saturation"
value={layer.filters.saturation}
min={0}
max={200}
defaultValue={100}
onChange={(v) => handleFilterChange('saturation', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Thermometer size={12} />}
label="Temperature"
value={layer.filters.hue}
min={-180}
max={180}
defaultValue={0}
onChange={(v) => handleFilterChange('hue', v)}
unit="°"
/>
<AdjustmentSlider
icon={<SunMedium size={12} />}
label="Exposure"
value={layer.filters.exposure}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('exposure', v)}
/>
<AdjustmentSlider
icon={<Vibrate size={12} />}
label="Vibrance"
value={layer.filters.vibrance}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('vibrance', v)}
/>
<AdjustmentSlider
icon={<Sunrise size={12} />}
label="Highlights"
value={layer.filters.highlights}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('highlights', v)}
/>
<AdjustmentSlider
icon={<SunDim size={12} />}
label="Shadows"
value={layer.filters.shadows}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('shadows', v)}
/>
<AdjustmentSlider
icon={<Aperture size={12} />}
label="Clarity"
value={layer.filters.clarity}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('clarity', v)}
/>
<AdjustmentSlider
icon={<Focus size={12} />}
label="Blur"
value={layer.filters.blur}
min={0}
max={50}
defaultValue={0}
onChange={(v) => handleFilterChange('blur', v)}
unit="px"
/>
{layer.filters.blur > 0 && (
<div className="space-y-2 pl-5 border-l-2 border-primary/30">
<div className="space-y-1.5">
<label className="text-[11px] text-foreground font-medium">Blur Type</label>
<div className="flex gap-1">
{([
{ type: 'gaussian' as BlurType, icon: <Focus size={12} />, label: 'Gaussian' },
{ type: 'motion' as BlurType, icon: <Move size={12} />, label: 'Motion' },
{ type: 'radial' as BlurType, icon: <Target size={12} />, label: 'Radial' },
]).map(({ type, icon, label }) => (
<button
key={type}
onClick={() => handleBlurTypeChange(type)}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-[10px] font-medium transition-all ${
layer.filters.blurType === type
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{icon}
{label}
</button>
))}
</div>
</div>
{layer.filters.blurType === 'motion' && (
<AdjustmentSlider
icon={<Move size={12} />}
label="Angle"
value={layer.filters.blurAngle}
min={0}
max={360}
defaultValue={0}
onChange={(v) => handleFilterChange('blurAngle', v)}
unit="°"
/>
)}
</div>
)}
<AdjustmentSlider
icon={<Sparkles size={12} />}
label="Sharpen"
value={layer.filters.sharpen}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('sharpen', v)}
unit="%"
/>
<AdjustmentSlider
icon={<CircleDot size={12} />}
label="Vignette"
value={layer.filters.vignette}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('vignette', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Scan size={12} />}
label="Grain"
value={layer.filters.grain}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('grain', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Film size={12} />}
label="Sepia"
value={layer.filters.sepia}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('sepia', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Minus size={12} />}
label="Invert"
value={layer.filters.invert}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('invert', v)}
unit="%"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { Crop, ImageIcon } from 'lucide-react';
import type { ImageLayer } from '../../../types/project';
interface Props {
layer: ImageLayer;
}
export function ImageControlsSection({ layer }: Props) {
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Image
</h4>
<div className="p-3 bg-secondary/30 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground">
<ImageIcon size={14} />
<span className="text-[11px]">Source: {layer.sourceId ? 'Linked' : 'None'}</span>
</div>
{layer.cropRect && (
<div className="flex items-center gap-2 text-muted-foreground mt-2">
<Crop size={14} />
<span className="text-[11px]">
Cropped: {Math.round(layer.cropRect.width)} × {Math.round(layer.cropRect.height)}
</span>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,467 @@
import { memo, lazy, Suspense, useState, createContext, useContext, ReactNode, JSX } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import { useUIStore } from '../../../stores/ui-store';
import { TransformSection } from './TransformSection';
import { AlignmentSection } from './AlignmentSection';
import { AppearanceSection } from './AppearanceSection';
import { EffectsSection } from './EffectsSection';
import { ArtboardSection } from './ArtboardSection';
import { PenSettingsSection } from './PenSettingsSection';
import { ColorHarmonySection } from './ColorHarmonySection';
import { ChevronRight, Sliders, Palette, Wand2, Sparkles, Image as ImageIcon, Layers } from 'lucide-react';
import { ScrollArea } from '@openreel/ui';
import type { Layer, ImageLayer, TextLayer, ShapeLayer } from '../../../types/project';
import type { Tool } from '../../../stores/ui-store';
const TOOL_FOCUSED_TOOLS = new Set<Tool>([
'pen', 'brush', 'eraser', 'gradient', 'paint-bucket',
'dodge', 'burn', 'sponge', 'blur', 'sharpen', 'smudge',
'clone-stamp', 'healing-brush', 'spot-healing', 'liquify',
'marquee-rect', 'marquee-ellipse', 'lasso', 'lasso-polygon', 'magic-wand',
'free-transform', 'warp', 'perspective', 'crop'
]);
const ImageAdjustmentsSection = lazy(() => import('./ImageAdjustmentsSection').then(m => ({ default: m.ImageAdjustmentsSection })));
const FilterPresetsSection = lazy(() => import('./FilterPresetsSection').then(m => ({ default: m.FilterPresetsSection })));
const CropSection = lazy(() => import('./CropSection').then(m => ({ default: m.CropSection })));
const ImageControlsSection = lazy(() => import('./ImageControlsSection').then(m => ({ default: m.ImageControlsSection })));
const BackgroundRemovalSection = lazy(() => import('./BackgroundRemovalSection').then(m => ({ default: m.BackgroundRemovalSection })));
const TextSection = lazy(() => import('./TextSection').then(m => ({ default: m.TextSection })));
const ShapeSection = lazy(() => import('./ShapeSection').then(m => ({ default: m.ShapeSection })));
const LevelsSection = lazy(() => import('./LevelsSection').then(m => ({ default: m.LevelsSection })));
const CurvesSection = lazy(() => import('./CurvesSection').then(m => ({ default: m.CurvesSection })));
const ColorBalanceSection = lazy(() => import('./ColorBalanceSection').then(m => ({ default: m.ColorBalanceSection })));
const SelectiveColorSection = lazy(() => import('./SelectiveColorSection').then(m => ({ default: m.SelectiveColorSection })));
const BlackWhiteSection = lazy(() => import('./BlackWhiteSection').then(m => ({ default: m.BlackWhiteSection })));
const PhotoFilterSection = lazy(() => import('./PhotoFilterSection').then(m => ({ default: m.PhotoFilterSection })));
const ChannelMixerSection = lazy(() => import('./ChannelMixerSection').then(m => ({ default: m.ChannelMixerSection })));
const GradientMapSection = lazy(() => import('./GradientMapSection').then(m => ({ default: m.GradientMapSection })));
const PosterizeSection = lazy(() => import('./PosterizeSection').then(m => ({ default: m.PosterizeSection })));
const ThresholdSection = lazy(() => import('./ThresholdSection').then(m => ({ default: m.ThresholdSection })));
const MaskSection = lazy(() => import('./MaskSection').then(m => ({ default: m.MaskSection })));
const SelectionToolsPanel = lazy(() => import('./SelectionToolsPanel').then(m => ({ default: m.SelectionToolsPanel })));
const EraserToolPanel = lazy(() => import('./EraserToolPanel').then(m => ({ default: m.EraserToolPanel })));
const DodgeBurnToolPanel = lazy(() => import('./DodgeBurnToolPanel').then(m => ({ default: m.DodgeBurnToolPanel })));
const CloneStampToolPanel = lazy(() => import('./CloneStampToolPanel').then(m => ({ default: m.CloneStampToolPanel })));
const HealingBrushToolPanel = lazy(() => import('./HealingBrushToolPanel').then(m => ({ default: m.HealingBrushToolPanel })));
const SpotHealingToolPanel = lazy(() => import('./SpotHealingToolPanel').then(m => ({ default: m.SpotHealingToolPanel })));
const SpongeToolPanel = lazy(() => import('./SpongeToolPanel').then(m => ({ default: m.SpongeToolPanel })));
const LiquifyToolPanel = lazy(() => import('./LiquifyToolPanel').then(m => ({ default: m.LiquifyToolPanel })));
const TransformToolPanel = lazy(() => import('./TransformToolPanel').then(m => ({ default: m.TransformToolPanel })));
const BrushToolPanel = lazy(() => import('./BrushToolPanel').then(m => ({ default: m.BrushToolPanel })));
const BlurSharpenToolPanel = lazy(() => import('./BlurSharpenToolPanel').then(m => ({ default: m.BlurSharpenToolPanel })));
const SmudgeToolPanel = lazy(() => import('./SmudgeToolPanel').then(m => ({ default: m.SmudgeToolPanel })));
const GradientToolPanel = lazy(() => import('./GradientToolPanel').then(m => ({ default: m.GradientToolPanel })));
const PaintBucketToolPanel = lazy(() => import('./PaintBucketToolPanel').then(m => ({ default: m.PaintBucketToolPanel })));
function SectionLoader() {
return (
<div className="px-4 py-3">
<div className="h-4 w-24 animate-pulse bg-muted/40 rounded mb-3" />
<div className="space-y-2">
<div className="h-8 animate-pulse bg-muted/30 rounded" />
<div className="h-8 animate-pulse bg-muted/30 rounded" />
</div>
</div>
);
}
type AccordionContextType = {
openItems: string[];
toggle: (id: string) => void;
};
const AccordionContext = createContext<AccordionContextType | null>(null);
interface AccordionProps {
children: ReactNode;
defaultOpen?: string[];
}
function Accordion({ children, defaultOpen = [] }: AccordionProps) {
const [openItems, setOpenItems] = useState<string[]>(defaultOpen);
const toggle = (id: string) => {
setOpenItems(prev =>
prev.includes(id)
? prev.filter(item => item !== id)
: [...prev, id]
);
};
return (
<AccordionContext.Provider value={{ openItems, toggle }}>
<div className="divide-y divide-border">{children}</div>
</AccordionContext.Provider>
);
}
interface AccordionItemProps {
id: string;
icon?: React.ElementType;
title: string;
children: ReactNode;
badge?: number;
}
function AccordionItem({ id, icon: Icon, title, children, badge }: AccordionItemProps) {
const context = useContext(AccordionContext);
if (!context) return null;
const { openItems, toggle } = context;
const isOpen = openItems.includes(id);
return (
<div>
<button
onClick={() => toggle(id)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
>
<ChevronRight
size={14}
className={`text-muted-foreground shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`}
/>
{Icon && <Icon size={16} className="text-muted-foreground shrink-0" />}
<span className="text-sm font-medium text-foreground flex-1">{title}</span>
{badge !== undefined && badge > 0 && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">
{badge}
</span>
)}
</button>
{isOpen && (
<div className="pb-4">
{children}
</div>
)}
</div>
);
}
function renderToolPanel(tool: Tool, imageLayer?: ImageLayer): JSX.Element | null {
const SELECTION_TOOLS = ['marquee-rect', 'marquee-ellipse', 'lasso', 'lasso-polygon', 'magic-wand'];
const TRANSFORM_TOOLS = ['free-transform', 'warp', 'perspective'];
if (SELECTION_TOOLS.includes(tool)) {
return (
<Suspense fallback={<SectionLoader />}>
<SelectionToolsPanel />
</Suspense>
);
}
if (TRANSFORM_TOOLS.includes(tool)) {
return (
<Suspense fallback={<SectionLoader />}>
<TransformToolPanel />
</Suspense>
);
}
switch (tool) {
case 'pen':
return <PenSettingsSection />;
case 'brush':
return (
<Suspense fallback={<SectionLoader />}>
<BrushToolPanel />
</Suspense>
);
case 'eraser':
return (
<Suspense fallback={<SectionLoader />}>
<EraserToolPanel />
</Suspense>
);
case 'gradient':
return (
<Suspense fallback={<SectionLoader />}>
<GradientToolPanel />
</Suspense>
);
case 'dodge':
case 'burn':
return (
<Suspense fallback={<SectionLoader />}>
<DodgeBurnToolPanel />
</Suspense>
);
case 'sponge':
return (
<Suspense fallback={<SectionLoader />}>
<SpongeToolPanel />
</Suspense>
);
case 'blur':
case 'sharpen':
return (
<Suspense fallback={<SectionLoader />}>
<BlurSharpenToolPanel />
</Suspense>
);
case 'smudge':
return (
<Suspense fallback={<SectionLoader />}>
<SmudgeToolPanel />
</Suspense>
);
case 'clone-stamp':
return (
<Suspense fallback={<SectionLoader />}>
<CloneStampToolPanel />
</Suspense>
);
case 'healing-brush':
return (
<Suspense fallback={<SectionLoader />}>
<HealingBrushToolPanel />
</Suspense>
);
case 'spot-healing':
return (
<Suspense fallback={<SectionLoader />}>
<SpotHealingToolPanel />
</Suspense>
);
case 'liquify':
return (
<Suspense fallback={<SectionLoader />}>
<LiquifyToolPanel />
</Suspense>
);
case 'paint-bucket':
return (
<Suspense fallback={<SectionLoader />}>
<PaintBucketToolPanel />
</Suspense>
);
case 'crop':
if (imageLayer) {
return (
<Suspense fallback={<SectionLoader />}>
<CropSection layer={imageLayer} />
</Suspense>
);
}
return null;
default:
return null;
}
}
function InspectorContent() {
const { project, selectedLayerIds, selectedArtboardId } = useProjectStore();
const { activeTool } = useUIStore();
const selectedLayers = selectedLayerIds
.map((id) => project?.layers[id])
.filter((layer): layer is Layer => layer !== undefined);
const singleLayer = selectedLayers.length === 1 ? selectedLayers[0] : null;
const imageLayer = singleLayer?.type === 'image' ? (singleLayer as ImageLayer) : undefined;
if (TOOL_FOCUSED_TOOLS.has(activeTool)) {
const toolPanel = renderToolPanel(activeTool, imageLayer);
if (toolPanel) {
return (
<ScrollArea className="h-full">
<div className="p-4">
{toolPanel}
</div>
</ScrollArea>
);
}
}
if (selectedLayers.length > 1) {
return (
<ScrollArea className="h-full">
<div className="p-4">
<div className="flex items-center gap-2 mb-6">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<Layers size={16} className="text-primary" />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">
{selectedLayers.length} layers
</h3>
<p className="text-xs text-muted-foreground">Multiple selection</p>
</div>
</div>
<AlignmentSection layers={selectedLayers} />
</div>
</ScrollArea>
);
}
if (!singleLayer) {
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
if (artboard) {
return (
<ScrollArea className="h-full">
<div className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-4">Artboard</h3>
<ArtboardSection artboard={artboard} />
</div>
</ScrollArea>
);
}
return (
<div className="h-full flex items-center justify-center p-6">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-muted/50 flex items-center justify-center">
<Layers size={20} className="text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Select a layer to view<br />and edit its properties
</p>
</div>
</div>
);
}
const getLayerIcon = () => {
switch (singleLayer.type) {
case 'image': return ImageIcon;
case 'text': return () => <span className="text-sm font-bold">T</span>;
case 'shape': return () => <span className="text-sm"></span>;
default: return Layers;
}
};
const LayerIcon = getLayerIcon();
return (
<ScrollArea className="h-full">
<div className="pb-8">
<div className="px-4 py-4 border-b border-border bg-card/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-muted/50 flex items-center justify-center shrink-0">
<LayerIcon size={18} className="text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground truncate">
{singleLayer.name}
</h3>
<p className="text-xs text-muted-foreground capitalize">{singleLayer.type} layer</p>
</div>
</div>
</div>
<Accordion defaultOpen={['transform', 'appearance', 'quick-filters', 'basic-adjustments']}>
<AccordionItem id="transform" icon={Sliders} title="Transform & Position">
<div className="px-4 space-y-4">
<TransformSection layer={singleLayer} />
<div className="pt-2">
<AlignmentSection layers={[singleLayer]} />
</div>
</div>
</AccordionItem>
<AccordionItem id="appearance" icon={Palette} title="Appearance">
<div className="px-4">
<AppearanceSection layer={singleLayer} />
</div>
</AccordionItem>
<AccordionItem id="effects" icon={Sparkles} title="Effects">
<EffectsSection layer={singleLayer} />
</AccordionItem>
{singleLayer.type === 'image' && (
<Suspense fallback={<SectionLoader />}>
<AccordionItem id="image-controls" icon={ImageIcon} title="Image Controls">
<div className="px-4 space-y-4">
<ImageControlsSection layer={singleLayer as ImageLayer} />
<CropSection layer={singleLayer as ImageLayer} />
<BackgroundRemovalSection layer={singleLayer as ImageLayer} />
</div>
</AccordionItem>
<AccordionItem id="quick-filters" icon={Wand2} title="Quick Filters">
<FilterPresetsSection layer={singleLayer as ImageLayer} />
</AccordionItem>
<AccordionItem id="basic-adjustments" icon={Sliders} title="Basic Adjustments">
<ImageAdjustmentsSection layer={singleLayer as ImageLayer} />
</AccordionItem>
<AccordionItem id="tonal" icon={Sliders} title="Tonal Adjustments">
<div className="space-y-0">
<LevelsSection layer={singleLayer} />
<CurvesSection layer={singleLayer} />
<PosterizeSection layer={singleLayer} />
<ThresholdSection layer={singleLayer} />
</div>
</AccordionItem>
<AccordionItem id="color" icon={Palette} title="Color Adjustments">
<div className="space-y-0">
<ColorBalanceSection layer={singleLayer} />
<SelectiveColorSection layer={singleLayer} />
<PhotoFilterSection layer={singleLayer} />
<ChannelMixerSection layer={singleLayer} />
<GradientMapSection layer={singleLayer} />
<BlackWhiteSection layer={singleLayer} />
</div>
</AccordionItem>
<AccordionItem id="mask" icon={Layers} title="Mask">
<MaskSection layer={singleLayer} />
</AccordionItem>
</Suspense>
)}
{singleLayer.type === 'text' && (
<Suspense fallback={<SectionLoader />}>
<AccordionItem id="text-settings" title="Text Settings">
<div className="px-4">
<TextSection layer={singleLayer as TextLayer} />
</div>
</AccordionItem>
<AccordionItem id="color-harmony" icon={Palette} title="Color Harmony">
<div className="px-4">
<ColorHarmonySection
baseColor={(singleLayer as TextLayer).style.color}
onColorSelect={(color) => {
useProjectStore.getState().updateLayer<TextLayer>(singleLayer.id, {
style: { ...(singleLayer as TextLayer).style, color },
});
}}
/>
</div>
</AccordionItem>
</Suspense>
)}
{singleLayer.type === 'shape' && (
<Suspense fallback={<SectionLoader />}>
<AccordionItem id="shape-settings" title="Shape Settings">
<div className="px-4">
<ShapeSection layer={singleLayer as ShapeLayer} />
</div>
</AccordionItem>
{(singleLayer as ShapeLayer).shapeStyle.fill && (
<AccordionItem id="color-harmony" icon={Palette} title="Color Harmony">
<div className="px-4">
<ColorHarmonySection
baseColor={(singleLayer as ShapeLayer).shapeStyle.fill!}
onColorSelect={(color) => {
useProjectStore.getState().updateLayer<ShapeLayer>(singleLayer.id, {
shapeStyle: { ...(singleLayer as ShapeLayer).shapeStyle, fill: color },
});
}}
/>
</div>
</AccordionItem>
)}
</Suspense>
)}
</Accordion>
</div>
</ScrollArea>
);
}
export const Inspector = memo(InspectorContent);

View file

@ -0,0 +1,213 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { LevelsChannel } from '../../../types/adjustments';
import { DEFAULT_LEVELS } from '../../../types/adjustments';
import { Activity, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type ChannelType = 'master' | 'red' | 'green' | 'blue';
interface LevelsSliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}
function LevelsSlider({ label, value, min, max, step = 1, onChange }: LevelsSliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">{value.toFixed(step < 1 ? 2 : 0)}</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
}}
/>
</div>
);
}
export function LevelsSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeChannel, setActiveChannel] = useState<ChannelType>('master');
const [isExpanded, setIsExpanded] = useState(true);
const levels = layer.levels;
const currentChannel = levels[activeChannel];
const handleChannelChange = (key: keyof LevelsChannel, value: number) => {
updateLayer(layer.id, {
levels: {
...levels,
[activeChannel]: {
...currentChannel,
[key]: value,
},
},
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
levels: {
...levels,
enabled,
},
});
};
const resetLevels = () => {
updateLayer(layer.id, {
levels: { ...DEFAULT_LEVELS },
});
};
const channelColors: Record<ChannelType, string> = {
master: 'bg-foreground',
red: 'bg-red-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
};
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Activity size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Levels</span>
{levels.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<div className="flex items-center gap-1">
<input
type="checkbox"
checked={levels.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{(['master', 'red', 'green', 'blue'] as ChannelType[]).map((channel) => (
<button
key={channel}
onClick={() => setActiveChannel(channel)}
className={`px-2 py-1 text-[10px] rounded transition-colors ${
activeChannel === channel
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${channelColors[channel]}`} />
{channel.charAt(0).toUpperCase()}
</button>
))}
</div>
<button
onClick={resetLevels}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset Levels"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-2.5 pt-1">
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Input Levels</span>
<div className="flex gap-2">
<div className="flex-1">
<LevelsSlider
label="Black"
value={currentChannel.inputBlack}
min={0}
max={255}
onChange={(v) => handleChannelChange('inputBlack', v)}
/>
</div>
<div className="flex-1">
<LevelsSlider
label="White"
value={currentChannel.inputWhite}
min={0}
max={255}
onChange={(v) => handleChannelChange('inputWhite', v)}
/>
</div>
</div>
</div>
<LevelsSlider
label="Gamma"
value={currentChannel.gamma}
min={0.1}
max={10}
step={0.01}
onChange={(v) => handleChannelChange('gamma', v)}
/>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Output Levels</span>
<div className="flex gap-2">
<div className="flex-1">
<LevelsSlider
label="Black"
value={currentChannel.outputBlack}
min={0}
max={255}
onChange={(v) => handleChannelChange('outputBlack', v)}
/>
</div>
<div className="flex-1">
<LevelsSlider
label="White"
value={currentChannel.outputWhite}
min={0}
max={255}
onChange={(v) => handleChannelChange('outputWhite', v)}
/>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,152 @@
import { useUIStore } from '../../../stores/ui-store';
import { Waves, RotateCcw, ArrowRight, Undo2, Sparkles, RotateCw, RotateCcw as Counterclockwise, Minus, Plus, ArrowLeft, Snowflake, Flame } from 'lucide-react';
const liquifyTools = [
{ id: 'forward-warp', label: 'Forward Warp', icon: ArrowRight },
{ id: 'reconstruct', label: 'Reconstruct', icon: Undo2 },
{ id: 'smooth', label: 'Smooth', icon: Sparkles },
{ id: 'twirl-clockwise', label: 'Twirl CW', icon: RotateCw },
{ id: 'twirl-counterclockwise', label: 'Twirl CCW', icon: Counterclockwise },
{ id: 'pucker', label: 'Pucker', icon: Minus },
{ id: 'bloat', label: 'Bloat', icon: Plus },
{ id: 'push-left', label: 'Push Left', icon: ArrowLeft },
{ id: 'freeze', label: 'Freeze', icon: Snowflake },
{ id: 'thaw', label: 'Thaw', icon: Flame },
] as const;
export function LiquifyToolPanel() {
const { liquifySettings, setLiquifySettings } = useUIStore();
const resetSettings = () => {
setLiquifySettings({
brushSize: 100,
brushDensity: 50,
brushPressure: 100,
brushRate: 80,
tool: 'forward-warp',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Waves size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Liquify</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Tool</span>
<div className="grid grid-cols-5 gap-1">
{liquifyTools.map((tool) => {
const Icon = tool.icon;
return (
<button
key={tool.id}
onClick={() => setLiquifySettings({ tool: tool.id })}
className={`p-2 rounded transition-colors ${
liquifySettings.tool === tool.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title={tool.label}
>
<Icon size={14} />
</button>
);
})}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Brush Size</span>
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushSize}px</span>
</div>
<input
type="range"
min={1}
max={1500}
value={liquifySettings.brushSize}
onChange={(e) => setLiquifySettings({ brushSize: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Density</span>
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushDensity}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={liquifySettings.brushDensity}
onChange={(e) => setLiquifySettings({ brushDensity: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Pressure</span>
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushPressure}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={liquifySettings.brushPressure}
onChange={(e) => setLiquifySettings({ brushPressure: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Rate</span>
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushRate}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={liquifySettings.brushRate}
onChange={(e) => setLiquifySettings({ brushRate: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,293 @@
import { useProjectStore } from '../../../stores/project-store';
import { useSelectionStore } from '../../../stores/selection-store';
import type { Layer } from '../../../types/project';
import type { LayerMask } from '../../../types/mask';
import {
Circle,
Eye,
EyeOff,
Link,
Unlink,
Trash2,
RotateCcw,
Plus,
Download,
} from 'lucide-react';
interface Props {
layer: Layer;
}
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 0 : 0)}
{label === 'Density' || label === 'Feather' ? '%' : 'px'}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
}}
/>
</div>
);
}
export function MaskSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const { active: selection, clearSelection } = useSelectionStore();
const mask = layer.mask;
const hasMask = mask !== null;
const hasSelection = selection !== null;
const handleAddMask = (reveal: boolean) => {
const baseMask: LayerMask = {
id: `mask-${Date.now()}`,
type: 'pixel',
enabled: true,
linked: true,
density: 100,
feather: 0,
invert: !reveal,
data: null,
vectorPath: selection ? [...selection.path] : null,
};
updateLayer(layer.id, { mask: baseMask });
if (selection) {
clearSelection();
}
};
const handleDeleteMask = () => {
updateLayer(layer.id, { mask: null });
};
const handleToggleMaskEnabled = () => {
if (!mask) return;
updateLayer(layer.id, {
mask: { ...mask, enabled: !mask.enabled },
});
};
const handleToggleMaskLinked = () => {
if (!mask) return;
updateLayer(layer.id, {
mask: { ...mask, linked: !mask.linked },
});
};
const handleToggleMaskInvert = () => {
if (!mask) return;
updateLayer(layer.id, {
mask: { ...mask, invert: !mask.invert },
});
};
const handleDensityChange = (density: number) => {
if (!mask) return;
updateLayer(layer.id, {
mask: { ...mask, density },
});
};
const handleFeatherChange = (feather: number) => {
if (!mask) return;
updateLayer(layer.id, {
mask: { ...mask, feather },
});
};
const handleToggleClippingMask = () => {
updateLayer(layer.id, { clippingMask: !layer.clippingMask });
};
return (
<div className="border-b border-border">
<div className="px-3 py-2">
<span className="text-xs font-medium">Masks</span>
</div>
<div className="px-3 pb-3 space-y-3">
{!hasMask ? (
<div className="space-y-2">
<p className="text-[10px] text-muted-foreground">
{hasSelection
? 'Create mask from current selection'
: 'Add a mask to control layer visibility'}
</p>
<div className="flex gap-1.5">
<button
onClick={() => handleAddMask(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<Plus size={10} />
Reveal All
</button>
<button
onClick={() => handleAddMask(false)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<Plus size={10} />
Hide All
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2 rounded bg-secondary/50">
<div className="w-8 h-8 rounded bg-gradient-to-br from-white to-black border border-border" />
<div className="flex-1 min-w-0">
<p className="text-[10px] font-medium truncate">
{mask.type === 'pixel' ? 'Pixel Mask' : 'Vector Mask'}
</p>
<p className="text-[9px] text-muted-foreground">
{mask.enabled ? 'Enabled' : 'Disabled'}
{mask.invert ? ' • Inverted' : ''}
</p>
</div>
</div>
<div className="flex gap-1">
<button
onClick={handleToggleMaskEnabled}
className={`flex-1 p-1.5 rounded transition-colors ${
mask.enabled
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
title={mask.enabled ? 'Disable Mask' : 'Enable Mask'}
>
{mask.enabled ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
<button
onClick={handleToggleMaskLinked}
className={`flex-1 p-1.5 rounded transition-colors ${
mask.linked
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
title={mask.linked ? 'Unlink Mask' : 'Link Mask'}
>
{mask.linked ? <Link size={12} /> : <Unlink size={12} />}
</button>
<button
onClick={handleToggleMaskInvert}
className={`flex-1 p-1.5 rounded transition-colors ${
mask.invert
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
title={mask.invert ? 'Remove Invert' : 'Invert Mask'}
>
<RotateCcw size={12} />
</button>
<button
onClick={handleDeleteMask}
className="flex-1 p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Delete Mask"
>
<Trash2 size={12} />
</button>
</div>
<Slider
label="Density"
value={mask.density}
min={0}
max={100}
onChange={handleDensityChange}
/>
<Slider
label="Feather"
value={mask.feather}
min={0}
max={250}
onChange={handleFeatherChange}
/>
{hasSelection && (
<div className="pt-2 border-t border-border space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">
Apply Selection
</span>
<div className="flex gap-1.5">
<button
onClick={() => {}}
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Add to Mask
</button>
<button
onClick={() => {}}
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Subtract
</button>
</div>
</div>
)}
</div>
)}
<div className="pt-2 border-t border-border">
<button
onClick={handleToggleClippingMask}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-[10px] rounded transition-colors ${
layer.clippingMask
? 'bg-primary/10 text-primary'
: 'bg-secondary hover:bg-secondary/80'
}`}
>
<Circle size={10} className={layer.clippingMask ? 'fill-primary' : ''} />
<span>{layer.clippingMask ? 'Release Clipping Mask' : 'Create Clipping Mask'}</span>
</button>
</div>
<div className="flex gap-1.5">
<button
onClick={() => {}}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
title="Load mask from selection"
>
<Download size={10} />
Load Selection
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useUIStore } from '../../../stores/ui-store';
import { PaintBucket, RotateCcw } from 'lucide-react';
export function PaintBucketToolPanel() {
const { paintBucketSettings, setPaintBucketSettings, brushSettings, setBrushSettings } = useUIStore();
const resetSettings = () => {
setPaintBucketSettings({
color: '#000000',
tolerance: 32,
contiguous: true,
antiAlias: true,
opacity: 1,
fillType: 'foreground',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PaintBucket size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Paint Bucket</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Click on canvas to fill area with color.
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Fill Color</span>
<div className="flex items-center gap-2">
<input
type="color"
value={brushSettings.color}
onChange={(e) => setBrushSettings({ color: e.target.value })}
className="w-10 h-10 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={brushSettings.color}
onChange={(e) => setBrushSettings({ color: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs font-mono bg-secondary/50 border border-border rounded"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Tolerance</span>
<span className="text-xs font-mono text-muted-foreground">{paintBucketSettings.tolerance}</span>
</div>
<input
type="range"
min={0}
max={255}
value={paintBucketSettings.tolerance}
onChange={(e) => setPaintBucketSettings({ tolerance: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(paintBucketSettings.opacity * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={paintBucketSettings.opacity * 100}
onChange={(e) => setPaintBucketSettings({ opacity: Number(e.target.value) / 100 })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div className="space-y-2 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={paintBucketSettings.contiguous}
onChange={(e) => setPaintBucketSettings({ contiguous: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Contiguous
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={paintBucketSettings.antiAlias}
onChange={(e) => setPaintBucketSettings({ antiAlias: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Anti-alias
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
import { useUIStore } from '../../../stores/ui-store';
import { Pencil } from 'lucide-react';
export function PenSettingsSection() {
const { penSettings, setPenSettings } = useUIStore();
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Pencil size={16} className="text-primary" />
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Pen Settings
</h4>
</div>
<div className="space-y-3 p-3 bg-secondary/30 rounded-lg border border-border/50">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[11px] text-foreground font-medium">Color</label>
<input
type="color"
value={penSettings.color}
onChange={(e) => setPenSettings({ color: e.target.value })}
className="w-8 h-6 rounded border border-border cursor-pointer"
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[11px] text-foreground font-medium">Width</label>
<span className="text-[11px] font-mono text-muted-foreground">{penSettings.width}px</span>
</div>
<input
type="range"
value={penSettings.width}
min={1}
max={50}
onChange={(e) => setPenSettings({ width: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:cursor-pointer"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[11px] text-foreground font-medium">Opacity</label>
<span className="text-[11px] font-mono text-muted-foreground">{Math.round(penSettings.opacity * 100)}%</span>
</div>
<input
type="range"
value={penSettings.opacity}
min={0.1}
max={1}
step={0.1}
onChange={(e) => setPenSettings({ opacity: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:cursor-pointer"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
Click and drag on the canvas to draw
</p>
</div>
);
}

View file

@ -0,0 +1,179 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { PhotoFilterAdjustment } from '../../../types/adjustments';
import { DEFAULT_PHOTO_FILTER } from '../../../types/adjustments';
import { PHOTO_FILTER_COLORS } from '../../../adjustments/photo-filter';
import { SunDim, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
const FILTER_OPTIONS = [
{ id: 'warming-85', label: 'Warming (85)', group: 'Warming' },
{ id: 'warming-81', label: 'Warming (81)', group: 'Warming' },
{ id: 'cooling-80', label: 'Cooling (80)', group: 'Cooling' },
{ id: 'cooling-82', label: 'Cooling (82)', group: 'Cooling' },
{ id: 'custom', label: 'Custom Color', group: 'Custom' },
] as const;
type FilterType = typeof FILTER_OPTIONS[number]['id'];
export function PhotoFilterSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const photoFilter = layer.photoFilter;
const handleFilterChange = (filter: FilterType) => {
const color = filter === 'custom' ? photoFilter.color : (PHOTO_FILTER_COLORS[filter as keyof typeof PHOTO_FILTER_COLORS] ?? photoFilter.color);
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
filter,
color,
},
});
};
const handleDensityChange = (density: number) => {
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
density,
},
});
};
const handleColorChange = (color: string) => {
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
filter: 'custom',
color,
} as PhotoFilterAdjustment,
});
};
const handlePreserveLuminosityChange = (preserveLuminosity: boolean) => {
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
preserveLuminosity,
},
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
enabled,
},
});
};
const resetPhotoFilter = () => {
updateLayer(layer.id, {
photoFilter: { ...DEFAULT_PHOTO_FILTER },
});
};
const densityPercentage = photoFilter.density;
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<SunDim size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Photo Filter</span>
{photoFilter.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={photoFilter.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<select
value={photoFilter.filter}
onChange={(e) => handleFilterChange(e.target.value as FilterType)}
className="text-[10px] bg-secondary border-none rounded px-2 py-1 text-foreground flex-1"
>
{FILTER_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
<button
onClick={resetPhotoFilter}
className="p-1 ml-2 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">Color</span>
<input
type="color"
value={photoFilter.color}
onChange={(e) => handleColorChange(e.target.value)}
className="w-6 h-6 rounded border-none cursor-pointer"
/>
<span className="text-[10px] font-mono text-muted-foreground">{photoFilter.color}</span>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Density</span>
<span className="text-[10px] font-mono text-muted-foreground">{photoFilter.density}%</span>
</div>
<input
type="range"
value={photoFilter.density}
min={0}
max={100}
onChange={(e) => handleDensityChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${densityPercentage}%, hsl(var(--secondary)) ${densityPercentage}%, hsl(var(--secondary)) 100%)`
}}
/>
</div>
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={photoFilter.preserveLuminosity}
onChange={(e) => handlePreserveLuminosityChange(e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Preserve Luminosity
</label>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,104 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import { DEFAULT_POSTERIZE } from '../../../types/adjustments';
import { Layers, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
export function PosterizeSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const posterize = layer.posterize;
const handleLevelsChange = (levels: number) => {
updateLayer(layer.id, {
posterize: { ...posterize, levels },
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
posterize: { ...posterize, enabled },
});
};
const resetPosterize = () => {
updateLayer(layer.id, {
posterize: { ...DEFAULT_POSTERIZE },
});
};
const percentage = ((posterize.levels - 2) / 253) * 100;
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Layers size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Posterize</span>
{posterize.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={posterize.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Levels</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground">{posterize.levels}</span>
<button
onClick={resetPosterize}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
</div>
<input
type="range"
value={posterize.levels}
min={2}
max={255}
onChange={(e) => handleLevelsChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
}}
/>
<div className="flex justify-between text-[9px] text-muted-foreground">
<span>2</span>
<span>255</span>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,324 @@
import { useState } from 'react';
import { useUIStore } from '../../../stores/ui-store';
import { useSelectionStore } from '../../../stores/selection-store';
import { useProjectStore } from '../../../stores/project-store';
import {
Square,
Circle,
Lasso,
Pentagon,
Wand2,
Plus,
Minus,
BoxSelect,
Trash2,
RotateCcw,
Download,
Upload,
ChevronDown,
X,
} from 'lucide-react';
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 1 : 0)}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
}}
/>
</div>
);
}
export function SelectionToolsPanel() {
const {
activeTool,
setActiveTool,
selectionToolSettings,
setSelectionToolSettings,
magicWandSettings,
setMagicWandSettings,
} = useUIStore();
const {
active: selection,
saved: savedSelections,
clearSelection,
invertSelection,
featherSelection,
expandSelection,
contractSelection,
saveSelection,
loadSelection,
deleteSelection,
} = useSelectionStore();
const [showLoadMenu, setShowLoadMenu] = useState(false);
const { project } = useProjectStore();
const artboard = project?.artboards?.find((a) => a.id === project.activeArtboardId);
const canvasBounds = artboard
? { x: 0, y: 0, width: artboard.size.width, height: artboard.size.height }
: { x: 0, y: 0, width: 1920, height: 1080 };
const isSelectionTool = [
'marquee-rect',
'marquee-ellipse',
'lasso',
'lasso-polygon',
'magic-wand',
].includes(activeTool);
const hasSelection = selection !== null;
const selectionTools = [
{ id: 'marquee-rect' as const, icon: Square, label: 'Rectangular' },
{ id: 'marquee-ellipse' as const, icon: Circle, label: 'Elliptical' },
{ id: 'lasso' as const, icon: Lasso, label: 'Lasso' },
{ id: 'lasso-polygon' as const, icon: Pentagon, label: 'Polygonal' },
{ id: 'magic-wand' as const, icon: Wand2, label: 'Magic Wand' },
];
const selectionModes = [
{ id: 'new' as const, icon: Square, label: 'New' },
{ id: 'add' as const, icon: Plus, label: 'Add' },
{ id: 'subtract' as const, icon: Minus, label: 'Subtract' },
{ id: 'intersect' as const, icon: BoxSelect, label: 'Intersect' },
];
if (!isSelectionTool && !hasSelection) {
return null;
}
return (
<div className="border-b border-border">
<div className="px-3 py-2">
<span className="text-xs font-medium">Selection Tools</span>
</div>
<div className="px-3 pb-3 space-y-3">
<div className="flex flex-wrap gap-1">
{selectionTools.map((tool) => (
<button
key={tool.id}
onClick={() => setActiveTool(tool.id)}
className={`p-1.5 rounded transition-colors ${
activeTool === tool.id
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title={tool.label}
>
<tool.icon size={14} />
</button>
))}
</div>
{isSelectionTool && (
<>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Mode</span>
<div className="flex gap-1">
{selectionModes.map((mode) => (
<button
key={mode.id}
onClick={() => setSelectionToolSettings({ mode: mode.id })}
className={`flex-1 px-2 py-1.5 text-[10px] rounded transition-colors ${
selectionToolSettings.mode === mode.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
{mode.label}
</button>
))}
</div>
</div>
<Slider
label="Feather"
value={selectionToolSettings.feather}
min={0}
max={100}
onChange={(v) => setSelectionToolSettings({ feather: v })}
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={selectionToolSettings.antiAlias}
onChange={(e) => setSelectionToolSettings({ antiAlias: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
<span className="text-[10px] text-muted-foreground">Anti-alias</span>
</label>
{activeTool === 'magic-wand' && (
<div className="space-y-2.5 pt-2 border-t border-border">
<Slider
label="Tolerance"
value={magicWandSettings.tolerance}
min={0}
max={255}
onChange={(v) => setMagicWandSettings({ tolerance: v })}
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={magicWandSettings.contiguous}
onChange={(e) => setMagicWandSettings({ contiguous: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
<span className="text-[10px] text-muted-foreground">Contiguous</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={magicWandSettings.sampleAllLayers}
onChange={(e) => setMagicWandSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
<span className="text-[10px] text-muted-foreground">Sample All Layers</span>
</label>
</div>
)}
</>
)}
{hasSelection && (
<div className="space-y-2.5 pt-2 border-t border-border">
<span className="text-[10px] text-muted-foreground font-medium">Selection Actions</span>
<div className="grid grid-cols-2 gap-1.5">
<button
onClick={() => invertSelection(canvasBounds)}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<RotateCcw size={10} />
Invert
</button>
<button
onClick={() => clearSelection()}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<Trash2 size={10} />
Deselect
</button>
</div>
<div className="space-y-1.5">
<div className="flex gap-1.5">
<button
onClick={() => expandSelection(1)}
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Expand
</button>
<button
onClick={() => contractSelection(1)}
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Contract
</button>
</div>
<button
onClick={() => featherSelection(selectionToolSettings.feather || 5)}
className="w-full px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Feather ({selectionToolSettings.feather || 5}px)
</button>
</div>
<div className="flex gap-1.5">
<button
onClick={() => saveSelection(`Selection ${savedSelections.length + 1}`)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
title="Save Selection"
>
<Download size={10} />
Save
</button>
<div className="flex-1 relative">
<button
onClick={() => setShowLoadMenu(!showLoadMenu)}
disabled={savedSelections.length === 0}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Load Selection"
>
<Upload size={10} />
Load
{savedSelections.length > 0 && (
<ChevronDown size={8} className={`transition-transform ${showLoadMenu ? 'rotate-180' : ''}`} />
)}
</button>
{showLoadMenu && savedSelections.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-10 max-h-32 overflow-y-auto">
{savedSelections.map((sel, idx) => (
<div
key={sel.id}
className="flex items-center justify-between px-2 py-1.5 hover:bg-secondary/50 group"
>
<button
onClick={() => {
loadSelection(sel.id);
setShowLoadMenu(false);
}}
className="flex-1 text-left text-[10px] text-foreground truncate"
>
Selection {idx + 1}
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteSelection(sel.id);
}}
className="p-0.5 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={10} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,175 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { SelectiveColorValues, SelectiveColorAdjustment } from '../../../types/adjustments';
import { DEFAULT_SELECTIVE_COLOR } from '../../../types/adjustments';
import { Palette, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type ColorRange = 'reds' | 'yellows' | 'greens' | 'cyans' | 'blues' | 'magentas' | 'whites' | 'neutrals' | 'blacks';
const COLOR_RANGES: { id: ColorRange; label: string; color: string }[] = [
{ id: 'reds', label: 'Reds', color: 'bg-red-500' },
{ id: 'yellows', label: 'Yellows', color: 'bg-yellow-500' },
{ id: 'greens', label: 'Greens', color: 'bg-green-500' },
{ id: 'cyans', label: 'Cyans', color: 'bg-cyan-500' },
{ id: 'blues', label: 'Blues', color: 'bg-blue-500' },
{ id: 'magentas', label: 'Magentas', color: 'bg-pink-500' },
{ id: 'whites', label: 'Whites', color: 'bg-white border border-border' },
{ id: 'neutrals', label: 'Neutrals', color: 'bg-gray-500' },
{ id: 'blacks', label: 'Blacks', color: 'bg-gray-900' },
];
function ColorSlider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
const percentage = ((value + 100) / 200) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">{value}%</span>
</div>
<input
type="range"
value={value}
min={-100}
max={100}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--secondary)) 0%, hsl(var(--secondary)) 50%, hsl(var(--primary)) 50%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
}}
/>
</div>
);
}
export function SelectiveColorSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeRange, setActiveRange] = useState<ColorRange>('reds');
const [isExpanded, setIsExpanded] = useState(false);
const selectiveColor = layer.selectiveColor;
const currentRange = selectiveColor[activeRange];
const handleValueChange = (key: keyof SelectiveColorValues, value: number) => {
updateLayer(layer.id, {
selectiveColor: {
...selectiveColor,
[activeRange]: {
...currentRange,
[key]: value,
},
} as SelectiveColorAdjustment,
});
};
const handleMethodChange = (method: 'relative' | 'absolute') => {
updateLayer(layer.id, {
selectiveColor: {
...selectiveColor,
method,
} as SelectiveColorAdjustment,
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
selectiveColor: {
...selectiveColor,
enabled,
} as SelectiveColorAdjustment,
});
};
const resetSelectiveColor = () => {
updateLayer(layer.id, {
selectiveColor: { ...DEFAULT_SELECTIVE_COLOR },
});
};
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Selective Color</span>
{selectiveColor.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={selectiveColor.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{COLOR_RANGES.map((range) => (
<button
key={range.id}
onClick={() => setActiveRange(range.id)}
className={`w-5 h-5 rounded transition-all ${range.color} ${
activeRange === range.id ? 'ring-2 ring-primary ring-offset-1 ring-offset-background' : ''
}`}
title={range.label}
/>
))}
</div>
<button
onClick={resetSelectiveColor}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-2">
<ColorSlider label="Cyan" value={currentRange.cyan} onChange={(v) => handleValueChange('cyan', v)} />
<ColorSlider label="Magenta" value={currentRange.magenta} onChange={(v) => handleValueChange('magenta', v)} />
<ColorSlider label="Yellow" value={currentRange.yellow} onChange={(v) => handleValueChange('yellow', v)} />
<ColorSlider label="Black" value={currentRange.black} onChange={(v) => handleValueChange('black', v)} />
</div>
<div className="flex gap-1 pt-1">
{(['relative', 'absolute'] as const).map((method) => (
<button
key={method}
onClick={() => handleMethodChange(method)}
className={`flex-1 px-2 py-1 text-[10px] rounded transition-colors ${
selectiveColor.method === method
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{method.charAt(0).toUpperCase() + method.slice(1)}
</button>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,524 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { ShapeLayer, ShapeStyle, Gradient, FillType, StrokeDashType, NoiseFill } from '../../../types/project';
import { DEFAULT_NOISE_FILL } from '../../../types/project';
import { Slider } from '@openreel/ui';
import { GradientPicker } from '../../ui/GradientPicker';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@openreel/ui';
import { ChevronDown, Link, Unlink } from 'lucide-react';
const DASH_PATTERNS: { value: StrokeDashType; label: string; preview: string }[] = [
{ value: 'solid', label: 'Solid', preview: '━━━━━━' },
{ value: 'dashed', label: 'Dashed', preview: '─ ─ ─ ─' },
{ value: 'dotted', label: 'Dotted', preview: '· · · · ·' },
{ value: 'dash-dot', label: 'Dash-Dot', preview: '─ · ─ ·' },
{ value: 'long-dash', label: 'Long Dash', preview: '── ── ──' },
];
interface Props {
layer: ShapeLayer;
}
export function ShapeSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isFillOpen, setIsFillOpen] = useState(true);
const [isStrokeOpen, setIsStrokeOpen] = useState(false);
const handleStyleChange = (updates: Partial<ShapeStyle>) => {
updateLayer<ShapeLayer>(layer.id, {
shapeStyle: { ...layer.shapeStyle, ...updates },
});
};
const handleFillTypeChange = (fillType: FillType) => {
if (fillType === 'gradient' && !layer.shapeStyle.gradient) {
handleStyleChange({
fillType,
gradient: {
type: 'linear',
angle: 90,
stops: [
{ offset: 0, color: layer.shapeStyle.fill ?? '#3b82f6' },
{ offset: 1, color: '#8b5cf6' },
],
},
});
} else if (fillType === 'noise' && !layer.shapeStyle.noise) {
handleStyleChange({
fillType,
noise: {
...DEFAULT_NOISE_FILL,
baseColor: layer.shapeStyle.fill ?? DEFAULT_NOISE_FILL.baseColor,
},
});
} else {
handleStyleChange({ fillType });
}
};
const handleNoiseChange = (updates: Partial<NoiseFill>) => {
handleStyleChange({
noise: { ...(layer.shapeStyle.noise ?? DEFAULT_NOISE_FILL), ...updates },
});
};
const handleGradientChange = (gradient: Gradient) => {
handleStyleChange({ gradient });
};
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Shape
</h4>
<Collapsible open={isFillOpen} onOpenChange={setIsFillOpen}>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
<span className="text-xs font-medium">Fill</span>
<div className="flex items-center gap-2">
<div
className="w-5 h-5 rounded border border-input"
style={{
backgroundColor: layer.shapeStyle.fillType === 'solid' ? (layer.shapeStyle.fill ?? 'transparent') : undefined,
background: layer.shapeStyle.fillType === 'gradient' && layer.shapeStyle.gradient
? layer.shapeStyle.gradient.type === 'linear'
? `linear-gradient(${layer.shapeStyle.gradient.angle}deg, ${layer.shapeStyle.gradient.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')})`
: `radial-gradient(circle, ${layer.shapeStyle.gradient.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')})`
: undefined,
}}
/>
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isFillOpen ? 'rotate-180' : ''}`} />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div className="grid grid-cols-3 gap-1">
<button
onClick={() => handleFillTypeChange('solid')}
className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
layer.shapeStyle.fillType === 'solid'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Solid
</button>
<button
onClick={() => handleFillTypeChange('gradient')}
className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
layer.shapeStyle.fillType === 'gradient'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Gradient
</button>
<button
onClick={() => handleFillTypeChange('noise')}
className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
layer.shapeStyle.fillType === 'noise'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Noise
</button>
</div>
{layer.shapeStyle.fillType === 'solid' && (
<div>
<div className="flex items-center gap-2">
<button
onClick={() => handleStyleChange({ fill: layer.shapeStyle.fill ? null : '#3b82f6' })}
className={`w-8 h-8 rounded border border-input flex items-center justify-center ${
layer.shapeStyle.fill ? '' : 'bg-background'
}`}
style={{ backgroundColor: layer.shapeStyle.fill ?? undefined }}
title={layer.shapeStyle.fill ? 'Remove fill' : 'Add fill'}
>
{!layer.shapeStyle.fill && (
<span className="text-xs text-muted-foreground"></span>
)}
</button>
{layer.shapeStyle.fill && (
<>
<input
type="color"
value={layer.shapeStyle.fill}
onChange={(e) => handleStyleChange({ fill: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.shapeStyle.fill}
onChange={(e) => handleStyleChange({ fill: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</>
)}
</div>
</div>
)}
{layer.shapeStyle.fillType === 'gradient' && (
<GradientPicker
value={layer.shapeStyle.gradient}
onChange={handleGradientChange}
/>
)}
{layer.shapeStyle.fillType === 'noise' && (
<div className="space-y-3">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Base Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.shapeStyle.noise?.baseColor ?? DEFAULT_NOISE_FILL.baseColor}
onChange={(e) => handleNoiseChange({ baseColor: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.shapeStyle.noise?.baseColor ?? DEFAULT_NOISE_FILL.baseColor}
onChange={(e) => handleNoiseChange({ baseColor: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Noise Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.shapeStyle.noise?.noiseColor ?? DEFAULT_NOISE_FILL.noiseColor}
onChange={(e) => handleNoiseChange({ noiseColor: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.shapeStyle.noise?.noiseColor ?? DEFAULT_NOISE_FILL.noiseColor}
onChange={(e) => handleNoiseChange({ noiseColor: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Density</label>
<span className="text-[10px] text-muted-foreground">{Math.round((layer.shapeStyle.noise?.density ?? 0.5) * 100)}%</span>
</div>
<Slider
value={[(layer.shapeStyle.noise?.density ?? 0.5) * 100]}
onValueChange={([density]) => handleNoiseChange({ density: density / 100 })}
min={5}
max={100}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Grain Size</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.noise?.size ?? 2}px</span>
</div>
<Slider
value={[layer.shapeStyle.noise?.size ?? 2]}
onValueChange={([size]) => handleNoiseChange({ size })}
min={1}
max={10}
step={1}
/>
</div>
</div>
)}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Opacity</label>
<span className="text-[10px] text-muted-foreground">{Math.round(layer.shapeStyle.fillOpacity * 100)}%</span>
</div>
<Slider
value={[layer.shapeStyle.fillOpacity * 100]}
onValueChange={([opacity]) => handleStyleChange({ fillOpacity: opacity / 100 })}
min={0}
max={100}
step={1}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible open={isStrokeOpen} onOpenChange={setIsStrokeOpen}>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
<span className="text-xs font-medium">Stroke</span>
<div className="flex items-center gap-2">
<div
className="w-5 h-5 rounded border-2"
style={{ borderColor: layer.shapeStyle.stroke ?? '#71717a' }}
/>
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isStrokeOpen ? 'rotate-180' : ''}`} />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div className="flex items-center gap-2">
<button
onClick={() => handleStyleChange({ stroke: layer.shapeStyle.stroke ? null : '#000000' })}
className={`w-8 h-8 rounded border-2 flex items-center justify-center ${
layer.shapeStyle.stroke ? '' : 'border-input bg-background'
}`}
style={{ borderColor: layer.shapeStyle.stroke ?? undefined }}
title={layer.shapeStyle.stroke ? 'Remove stroke' : 'Add stroke'}
>
{!layer.shapeStyle.stroke && (
<span className="text-xs text-muted-foreground"></span>
)}
</button>
{layer.shapeStyle.stroke && (
<>
<input
type="color"
value={layer.shapeStyle.stroke}
onChange={(e) => handleStyleChange({ stroke: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.shapeStyle.stroke}
onChange={(e) => handleStyleChange({ stroke: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</>
)}
</div>
{layer.shapeStyle.stroke && (
<>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Width</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.strokeWidth}px</span>
</div>
<Slider
value={[layer.shapeStyle.strokeWidth]}
onValueChange={([width]) => handleStyleChange({ strokeWidth: width })}
min={1}
max={20}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Opacity</label>
<span className="text-[10px] text-muted-foreground">{Math.round(layer.shapeStyle.strokeOpacity * 100)}%</span>
</div>
<Slider
value={[layer.shapeStyle.strokeOpacity * 100]}
onValueChange={([opacity]) => handleStyleChange({ strokeOpacity: opacity / 100 })}
min={0}
max={100}
step={1}
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground mb-1.5 block">Dash Pattern</label>
<div className="grid grid-cols-1 gap-1">
{DASH_PATTERNS.map((pattern) => (
<button
key={pattern.value}
onClick={() => handleStyleChange({ strokeDash: pattern.value })}
className={`flex items-center justify-between px-2 py-1.5 text-xs rounded-md transition-colors ${
(layer.shapeStyle.strokeDash ?? 'solid') === pattern.value
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
<span>{pattern.label}</span>
<span className="font-mono text-[10px] opacity-70">{pattern.preview}</span>
</button>
))}
</div>
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
{layer.shapeType === 'rectangle' && (
<div className="p-3 space-y-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Corner Radius</label>
<button
onClick={() => {
const currentRadius = layer.shapeStyle.cornerRadius ?? 0;
const defaultCorners = { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 };
handleStyleChange({
individualCorners: !layer.shapeStyle.individualCorners,
corners: layer.shapeStyle.individualCorners
? (layer.shapeStyle.corners ?? defaultCorners)
: {
topLeft: currentRadius,
topRight: currentRadius,
bottomRight: currentRadius,
bottomLeft: currentRadius,
},
});
}}
className={`flex items-center gap-1 px-2 py-1 text-[10px] rounded-md transition-colors ${
layer.shapeStyle.individualCorners
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title={layer.shapeStyle.individualCorners ? 'Link corners' : 'Unlink corners'}
>
{layer.shapeStyle.individualCorners ? <Unlink size={12} /> : <Link size={12} />}
{layer.shapeStyle.individualCorners ? 'Individual' : 'Uniform'}
</button>
</div>
{!layer.shapeStyle.individualCorners ? (
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">All Corners</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.cornerRadius}px</span>
</div>
<Slider
value={[layer.shapeStyle.cornerRadius]}
onValueChange={([radius]) => handleStyleChange({ cornerRadius: radius })}
min={0}
max={100}
step={1}
/>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Top Left</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.corners?.topLeft ?? 0}px</span>
</div>
<Slider
value={[layer.shapeStyle.corners?.topLeft ?? 0]}
onValueChange={([radius]) =>
handleStyleChange({
corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), topLeft: radius },
})
}
min={0}
max={100}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Top Right</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.corners?.topRight ?? 0}px</span>
</div>
<Slider
value={[layer.shapeStyle.corners?.topRight ?? 0]}
onValueChange={([radius]) =>
handleStyleChange({
corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), topRight: radius },
})
}
min={0}
max={100}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Bottom Left</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.corners?.bottomLeft ?? 0}px</span>
</div>
<Slider
value={[layer.shapeStyle.corners?.bottomLeft ?? 0]}
onValueChange={([radius]) =>
handleStyleChange({
corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), bottomLeft: radius },
})
}
min={0}
max={100}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Bottom Right</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.corners?.bottomRight ?? 0}px</span>
</div>
<Slider
value={[layer.shapeStyle.corners?.bottomRight ?? 0]}
onValueChange={([radius]) =>
handleStyleChange({
corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), bottomRight: radius },
})
}
min={0}
max={100}
step={1}
/>
</div>
</div>
)}
</div>
)}
{layer.shapeType === 'polygon' && (
<div className="p-3 space-y-2 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Sides</label>
<span className="text-[10px] text-muted-foreground">{layer.sides ?? 6}</span>
</div>
<Slider
value={[layer.sides ?? 6]}
onValueChange={([sides]) => updateLayer<ShapeLayer>(layer.id, { sides })}
min={3}
max={12}
step={1}
/>
</div>
)}
{layer.shapeType === 'star' && (
<div className="p-3 space-y-3 bg-secondary/50 rounded-lg">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Points</label>
<span className="text-[10px] text-muted-foreground">{layer.sides ?? 5}</span>
</div>
<Slider
value={[layer.sides ?? 5]}
onValueChange={([sides]) => updateLayer<ShapeLayer>(layer.id, { sides })}
min={3}
max={20}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Inner Radius</label>
<span className="text-[10px] text-muted-foreground">{Math.round((layer.innerRadius ?? 0.4) * 100)}%</span>
</div>
<Slider
value={[Math.round((layer.innerRadius ?? 0.4) * 100)]}
onValueChange={([ratio]) => updateLayer<ShapeLayer>(layer.id, { innerRadius: ratio / 100 })}
min={10}
max={90}
step={1}
/>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,100 @@
import { useUIStore } from '../../../stores/ui-store';
import { Blend, RotateCcw } from 'lucide-react';
export function SmudgeToolPanel() {
const { smudgeSettings, setSmudgeSettings } = useUIStore();
const resetSettings = () => {
setSmudgeSettings({
size: 30,
strength: 50,
fingerPainting: false,
sampleAllLayers: false,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Blend size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Smudge Tool</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Drag to smudge and blend colors together.
</p>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{smudgeSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={smudgeSettings.size}
onChange={(e) => setSmudgeSettings({ size: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Strength</span>
<span className="text-xs font-mono text-muted-foreground">{smudgeSettings.strength}%</span>
</div>
<input
type="range"
min={1}
max={100}
value={smudgeSettings.strength}
onChange={(e) => setSmudgeSettings({ strength: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div className="flex flex-col gap-2 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={smudgeSettings.fingerPainting}
onChange={(e) => setSmudgeSettings({ fingerPainting: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Finger Painting
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={smudgeSettings.sampleAllLayers}
onChange={(e) => setSmudgeSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Sample All Layers
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
import { useUIStore } from '../../../stores/ui-store';
import { Droplet, RotateCcw } from 'lucide-react';
export function SpongeToolPanel() {
const { spongeSettings, setSpongeSettings } = useUIStore();
const resetSettings = () => {
setSpongeSettings({
size: 30,
flow: 50,
mode: 'desaturate',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Droplet size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Sponge Tool</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Paint to saturate or desaturate color in specific areas.
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => setSpongeSettings({ mode: 'desaturate' })}
className={`px-3 py-2 text-xs rounded transition-colors ${
spongeSettings.mode === 'desaturate'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
Desaturate
</button>
<button
onClick={() => setSpongeSettings({ mode: 'saturate' })}
className={`px-3 py-2 text-xs rounded transition-colors ${
spongeSettings.mode === 'saturate'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
Saturate
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{spongeSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={spongeSettings.size}
onChange={(e) => setSpongeSettings({ size: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Flow</span>
<span className="text-xs font-mono text-muted-foreground">{spongeSettings.flow}%</span>
</div>
<input
type="range"
min={1}
max={100}
value={spongeSettings.flow}
onChange={(e) => setSpongeSettings({ flow: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { useUIStore } from '../../../stores/ui-store';
import { Bandage, RotateCcw } from 'lucide-react';
export function SpotHealingToolPanel() {
const { spotHealingSettings, setSpotHealingSettings } = useUIStore();
const resetSettings = () => {
setSpotHealingSettings({
size: 30,
type: 'content-aware',
sampleAllLayers: false,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bandage size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Spot Healing Brush</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Paint over blemishes or imperfections to automatically remove them.
</p>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{spotHealingSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={spotHealingSettings.size}
onChange={(e) => setSpotHealingSettings({ size: Number(e.target.value) })}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary"
/>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Type</span>
<div className="flex flex-col gap-1">
{([
{ id: 'content-aware', label: 'Content-Aware' },
{ id: 'proximity-match', label: 'Proximity Match' },
{ id: 'create-texture', label: 'Create Texture' },
] as const).map((option) => (
<button
key={option.id}
onClick={() => setSpotHealingSettings({ type: option.id })}
className={`px-3 py-2 text-xs rounded transition-colors text-left ${
spotHealingSettings.type === option.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div className="pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={spotHealingSettings.sampleAllLayers}
onChange={(e) => setSpotHealingSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Sample All Layers
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,595 @@
import { useProjectStore } from '../../../stores/project-store';
import type { TextLayer, TextStyle, TextFillType, Gradient } from '../../../types/project';
import { AlignLeft, AlignCenter, AlignRight, Bold, Italic, Underline, CaseUpper, CaseLower, CaseSensitive, Strikethrough, Type } from 'lucide-react';
import { FontPicker } from '../../ui/FontPicker';
import { GradientPicker } from '../../ui/GradientPicker';
import { Slider, Switch } from '@openreel/ui';
interface Props {
layer: TextLayer;
}
const FONT_SIZES = [8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 96, 128];
interface TextPreset {
id: string;
name: string;
style: Partial<TextStyle>;
}
const TEXT_PRESETS: TextPreset[] = [
{
id: 'heading-1',
name: 'Heading 1',
style: { fontSize: 72, fontWeight: 700, lineHeight: 1.1 },
},
{
id: 'heading-2',
name: 'Heading 2',
style: { fontSize: 48, fontWeight: 700, lineHeight: 1.2 },
},
{
id: 'heading-3',
name: 'Heading 3',
style: { fontSize: 36, fontWeight: 600, lineHeight: 1.2 },
},
{
id: 'subheading',
name: 'Subheading',
style: { fontSize: 24, fontWeight: 500, lineHeight: 1.3 },
},
{
id: 'body',
name: 'Body',
style: { fontSize: 16, fontWeight: 400, lineHeight: 1.5 },
},
{
id: 'body-large',
name: 'Body Large',
style: { fontSize: 18, fontWeight: 400, lineHeight: 1.6 },
},
{
id: 'caption',
name: 'Caption',
style: { fontSize: 12, fontWeight: 400, lineHeight: 1.4, color: '#a3a3a3' },
},
{
id: 'quote',
name: 'Quote',
style: { fontSize: 24, fontWeight: 400, fontStyle: 'italic' as const, lineHeight: 1.5 },
},
];
export function TextSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const handleContentChange = (content: string) => {
updateLayer<TextLayer>(layer.id, { content });
};
const handleStyleChange = (updates: Partial<TextStyle>) => {
updateLayer<TextLayer>(layer.id, {
style: { ...layer.style, ...updates },
});
};
const toggleBold = () => {
handleStyleChange({
fontWeight: layer.style.fontWeight >= 700 ? 400 : 700,
});
};
const toggleItalic = () => {
handleStyleChange({
fontStyle: layer.style.fontStyle === 'italic' ? 'normal' : 'italic',
});
};
const toggleUnderline = () => {
handleStyleChange({
textDecoration: layer.style.textDecoration === 'underline' ? 'none' : 'underline',
});
};
const toggleStrikethrough = () => {
handleStyleChange({
textDecoration: layer.style.textDecoration === 'line-through' ? 'none' : 'line-through',
});
};
const transformToUppercase = () => {
handleContentChange(layer.content.toUpperCase());
};
const transformToLowercase = () => {
handleContentChange(layer.content.toLowerCase());
};
const transformToCapitalize = () => {
const capitalized = layer.content
.toLowerCase()
.replace(/(?:^|\s)\S/g, (char) => char.toUpperCase());
handleContentChange(capitalized);
};
const applyPreset = (preset: TextPreset) => {
handleStyleChange(preset.style);
};
return (
<div className="space-y-4">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Text
</h4>
<div>
<div className="flex items-center gap-2 mb-2">
<Type size={14} className="text-muted-foreground" />
<label className="text-[10px] text-muted-foreground">Text Presets</label>
</div>
<div className="flex flex-wrap gap-1.5">
{TEXT_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => applyPreset(preset)}
className="px-2.5 py-1.5 text-[10px] rounded-md bg-secondary text-secondary-foreground hover:bg-accent transition-colors"
>
{preset.name}
</button>
))}
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Content</label>
<textarea
value={layer.content}
onChange={(e) => handleContentChange(e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary min-h-[60px] resize-none"
rows={3}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Font</label>
<FontPicker
value={layer.style.fontFamily}
onChange={(fontFamily) => handleStyleChange({ fontFamily })}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Size</label>
<select
value={layer.style.fontSize}
onChange={(e) => handleStyleChange({ fontSize: Number(e.target.value) })}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
>
{FONT_SIZES.map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Weight</label>
<select
value={layer.style.fontWeight}
onChange={(e) => handleStyleChange({ fontWeight: Number(e.target.value) })}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value={300}>Light</option>
<option value={400}>Regular</option>
<option value={500}>Medium</option>
<option value={600}>Semibold</option>
<option value={700}>Bold</option>
</select>
</div>
</div>
<div className="flex gap-1">
<button
onClick={toggleBold}
className={`p-2 rounded-md transition-colors ${
layer.style.fontWeight >= 700
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Bold"
>
<Bold size={14} />
</button>
<button
onClick={toggleItalic}
className={`p-2 rounded-md transition-colors ${
layer.style.fontStyle === 'italic'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Italic"
>
<Italic size={14} />
</button>
<button
onClick={toggleUnderline}
className={`p-2 rounded-md transition-colors ${
layer.style.textDecoration === 'underline'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Underline"
>
<Underline size={14} />
</button>
<button
onClick={toggleStrikethrough}
className={`p-2 rounded-md transition-colors ${
layer.style.textDecoration === 'line-through'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Strikethrough"
>
<Strikethrough size={14} />
</button>
<div className="w-px bg-border mx-1" />
<button
onClick={() => handleStyleChange({ textAlign: 'left' })}
className={`p-2 rounded-md transition-colors ${
layer.style.textAlign === 'left'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Align Left"
>
<AlignLeft size={14} />
</button>
<button
onClick={() => handleStyleChange({ textAlign: 'center' })}
className={`p-2 rounded-md transition-colors ${
layer.style.textAlign === 'center'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Align Center"
>
<AlignCenter size={14} />
</button>
<button
onClick={() => handleStyleChange({ textAlign: 'right' })}
className={`p-2 rounded-md transition-colors ${
layer.style.textAlign === 'right'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Align Right"
>
<AlignRight size={14} />
</button>
<div className="w-px bg-border mx-1" />
<button
onClick={transformToUppercase}
className="p-2 rounded-md transition-colors bg-secondary text-secondary-foreground hover:bg-accent"
title="UPPERCASE"
>
<CaseUpper size={14} />
</button>
<button
onClick={transformToLowercase}
className="p-2 rounded-md transition-colors bg-secondary text-secondary-foreground hover:bg-accent"
title="lowercase"
>
<CaseLower size={14} />
</button>
<button
onClick={transformToCapitalize}
className="p-2 rounded-md transition-colors bg-secondary text-secondary-foreground hover:bg-accent"
title="Capitalize"
>
<CaseSensitive size={14} />
</button>
</div>
<div className="space-y-3 p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Fill</label>
<div className="flex gap-1">
<button
onClick={() => handleStyleChange({ fillType: 'solid' as TextFillType })}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
(layer.style.fillType ?? 'solid') === 'solid'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Solid
</button>
<button
onClick={() => {
const gradient: Gradient = layer.style.gradient ?? {
type: 'linear',
angle: 90,
stops: [
{ offset: 0, color: layer.style.color },
{ offset: 1, color: '#8b5cf6' },
],
};
handleStyleChange({ fillType: 'gradient' as TextFillType, gradient });
}}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
layer.style.fillType === 'gradient'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Gradient
</button>
</div>
</div>
{(layer.style.fillType ?? 'solid') === 'solid' ? (
<div className="flex items-center gap-2">
<input
type="color"
value={layer.style.color}
onChange={(e) => handleStyleChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.style.color}
onChange={(e) => handleStyleChange({ color: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</div>
) : (
<GradientPicker
value={layer.style.gradient}
onChange={(gradient) => handleStyleChange({ gradient })}
/>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Line Height</label>
<input
type="number"
value={layer.style.lineHeight}
onChange={(e) => handleStyleChange({ lineHeight: Number(e.target.value) })}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
step={0.1}
min={0.5}
max={3}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Letter Spacing</label>
<input
type="number"
value={layer.style.letterSpacing}
onChange={(e) => handleStyleChange({ letterSpacing: Number(e.target.value) })}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
step={0.1}
/>
</div>
</div>
<div className="space-y-2 p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Text Stroke</label>
<button
onClick={() => handleStyleChange({ strokeColor: layer.style.strokeColor ? null : '#000000', strokeWidth: layer.style.strokeColor ? 0 : 2 })}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
layer.style.strokeColor
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
{layer.style.strokeColor ? 'On' : 'Off'}
</button>
</div>
{layer.style.strokeColor && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={layer.style.strokeColor}
onChange={(e) => handleStyleChange({ strokeColor: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.style.strokeColor}
onChange={(e) => handleStyleChange({ strokeColor: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Width</label>
<span className="text-[10px] text-muted-foreground">{layer.style.strokeWidth ?? 0}px</span>
</div>
<Slider
value={[layer.style.strokeWidth ?? 0]}
onValueChange={([width]) => handleStyleChange({ strokeWidth: width })}
min={0}
max={10}
step={0.5}
/>
</div>
</div>
)}
</div>
<div className="space-y-2 p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Background</label>
<button
onClick={() => handleStyleChange({ backgroundColor: layer.style.backgroundColor ? null : '#000000' })}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
layer.style.backgroundColor
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
{layer.style.backgroundColor ? 'On' : 'Off'}
</button>
</div>
{layer.style.backgroundColor && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={layer.style.backgroundColor}
onChange={(e) => handleStyleChange({ backgroundColor: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.style.backgroundColor}
onChange={(e) => handleStyleChange({ backgroundColor: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Padding</label>
<span className="text-[10px] text-muted-foreground">{layer.style.backgroundPadding ?? 8}px</span>
</div>
<Slider
value={[layer.style.backgroundPadding ?? 8]}
onValueChange={([padding]) => handleStyleChange({ backgroundPadding: padding })}
min={0}
max={32}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Radius</label>
<span className="text-[10px] text-muted-foreground">{layer.style.backgroundRadius ?? 4}px</span>
</div>
<Slider
value={[layer.style.backgroundRadius ?? 4]}
onValueChange={([radius]) => handleStyleChange({ backgroundRadius: radius })}
min={0}
max={32}
step={1}
/>
</div>
</div>
</div>
)}
</div>
<div className="space-y-2 p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Text Shadow</label>
<Switch
checked={layer.style.textShadow?.enabled ?? false}
onCheckedChange={(enabled) =>
handleStyleChange({
textShadow: {
...(layer.style.textShadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }),
enabled,
},
})
}
/>
</div>
{layer.style.textShadow?.enabled && (
<div className="space-y-3">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={(layer.style.textShadow?.color ?? 'rgba(0, 0, 0, 0.5)').startsWith('rgba') ? '#000000' : layer.style.textShadow?.color ?? '#000000'}
onChange={(e) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), color: e.target.value },
})
}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.style.textShadow?.color ?? 'rgba(0, 0, 0, 0.5)'}
onChange={(e) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), color: e.target.value },
})
}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur</label>
<span className="text-[10px] text-muted-foreground">{layer.style.textShadow?.blur ?? 4}px</span>
</div>
<Slider
value={[layer.style.textShadow?.blur ?? 4]}
onValueChange={([blur]) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), blur },
})
}
min={0}
max={50}
step={1}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset X</label>
<span className="text-[10px] text-muted-foreground">{layer.style.textShadow?.offsetX ?? 0}px</span>
</div>
<Slider
value={[layer.style.textShadow?.offsetX ?? 0]}
onValueChange={([offsetX]) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), offsetX },
})
}
min={-30}
max={30}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset Y</label>
<span className="text-[10px] text-muted-foreground">{layer.style.textShadow?.offsetY ?? 2}px</span>
</div>
<Slider
value={[layer.style.textShadow?.offsetY ?? 2]}
onValueChange={([offsetY]) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), offsetY },
})
}
min={-30}
max={30}
step={1}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import { DEFAULT_THRESHOLD } from '../../../types/adjustments';
import { Binary, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
export function ThresholdSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const threshold = layer.threshold;
const handleLevelChange = (level: number) => {
updateLayer(layer.id, {
threshold: { ...threshold, level },
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
threshold: { ...threshold, enabled },
});
};
const resetThreshold = () => {
updateLayer(layer.id, {
threshold: { ...DEFAULT_THRESHOLD },
});
};
const percentage = (threshold.level / 255) * 100;
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Binary size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Threshold</span>
{threshold.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={threshold.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Threshold Level</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground">{threshold.level}</span>
<button
onClick={resetThreshold}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
</div>
<input
type="range"
value={threshold.level}
min={0}
max={255}
onChange={(e) => handleLevelChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, #000000 0%, #000000 ${percentage}%, #ffffff ${percentage}%, #ffffff 100%)`
}}
/>
<div className="flex justify-between text-[9px] text-muted-foreground">
<span>0 (Black)</span>
<span>255 (White)</span>
</div>
<p className="text-[9px] text-muted-foreground">
Pixels below the threshold become black, above become white.
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,187 @@
import { FlipHorizontal2, FlipVertical2, RotateCw, RotateCcw } from 'lucide-react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
interface Props {
layer: Layer;
}
export function TransformSection({ layer }: Props) {
const { updateLayer, updateLayerTransform } = useProjectStore();
const { x, y, width, height, rotation, skewX, skewY, opacity } = layer.transform;
const flipH = layer.flipHorizontal ?? false;
const flipV = layer.flipVertical ?? false;
const handleChange = (key: string, value: number) => {
updateLayerTransform(layer.id, { [key]: value });
};
const handleFlipHorizontal = () => {
updateLayer(layer.id, { flipHorizontal: !flipH });
};
const handleFlipVertical = () => {
updateLayer(layer.id, { flipVertical: !flipV });
};
const handleRotate = (degrees: number) => {
updateLayerTransform(layer.id, {
rotation: (rotation + degrees) % 360,
});
};
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Transform
</h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">X</label>
<input
type="number"
value={Math.round(x)}
onChange={(e) => handleChange('x', Number(e.target.value))}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Y</label>
<input
type="number"
value={Math.round(y)}
onChange={(e) => handleChange('y', Number(e.target.value))}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Width</label>
<input
type="number"
value={Math.round(width)}
onChange={(e) => handleChange('width', Number(e.target.value))}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
min={1}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Height</label>
<input
type="number"
value={Math.round(height)}
onChange={(e) => handleChange('height', Number(e.target.value))}
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
min={1}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Rotation</label>
<div className="flex items-center gap-1">
<input
type="number"
value={Math.round(rotation)}
onChange={(e) => handleChange('rotation', Number(e.target.value))}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
<span className="text-xs text-muted-foreground">°</span>
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Opacity</label>
<div className="flex items-center gap-1">
<input
type="number"
value={Math.round(opacity * 100)}
onChange={(e) => handleChange('opacity', Number(e.target.value) / 100)}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
min={0}
max={100}
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Skew X</label>
<div className="flex items-center gap-1">
<input
type="number"
value={Math.round(skewX ?? 0)}
onChange={(e) => handleChange('skewX', Number(e.target.value))}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
min={-89}
max={89}
/>
<span className="text-xs text-muted-foreground">°</span>
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Skew Y</label>
<div className="flex items-center gap-1">
<input
type="number"
value={Math.round(skewY ?? 0)}
onChange={(e) => handleChange('skewY', Number(e.target.value))}
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
min={-89}
max={89}
/>
<span className="text-xs text-muted-foreground">°</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-1.5">
<button
onClick={handleFlipHorizontal}
className={`flex items-center justify-center gap-1 p-2 rounded-md border transition-all ${
flipH
? 'bg-primary/20 border-primary text-primary'
: 'bg-secondary/50 border-border text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title="Flip Horizontal"
>
<FlipHorizontal2 size={14} />
</button>
<button
onClick={handleFlipVertical}
className={`flex items-center justify-center gap-1 p-2 rounded-md border transition-all ${
flipV
? 'bg-primary/20 border-primary text-primary'
: 'bg-secondary/50 border-border text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title="Flip Vertical"
>
<FlipVertical2 size={14} />
</button>
<button
onClick={() => handleRotate(-90)}
className="flex items-center justify-center gap-1 p-2 rounded-md bg-secondary/50 border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-all"
title="Rotate 90° Counter-clockwise"
>
<RotateCcw size={14} />
</button>
<button
onClick={() => handleRotate(90)}
className="flex items-center justify-center gap-1 p-2 rounded-md bg-secondary/50 border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-all"
title="Rotate 90° Clockwise"
>
<RotateCw size={14} />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,103 @@
import { useUIStore } from '../../../stores/ui-store';
import { Move, RotateCcw, Scale, RotateCw, ArrowUpDown, Maximize2, Grid3x3 } from 'lucide-react';
const transformModes = [
{ id: 'free', label: 'Free', icon: Move },
{ id: 'scale', label: 'Scale', icon: Scale },
{ id: 'rotate', label: 'Rotate', icon: RotateCw },
{ id: 'skew', label: 'Skew', icon: ArrowUpDown },
{ id: 'distort', label: 'Distort', icon: Maximize2 },
{ id: 'perspective', label: 'Perspective', icon: Maximize2 },
{ id: 'warp', label: 'Warp', icon: Grid3x3 },
] as const;
export function TransformToolPanel() {
const { transformSettings, setTransformSettings } = useUIStore();
const resetSettings = () => {
setTransformSettings({
mode: 'free',
maintainAspectRatio: false,
interpolation: 'bicubic',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Move size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Transform</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Select a layer and use handles to transform. Press Enter to apply, Escape to cancel.
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
<div className="grid grid-cols-4 gap-1">
{transformModes.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.id}
onClick={() => setTransformSettings({ mode: mode.id })}
className={`flex flex-col items-center gap-1 p-2 rounded transition-colors ${
transformSettings.mode === mode.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title={mode.label}
>
<Icon size={14} />
<span className="text-[9px]">{mode.label}</span>
</button>
);
})}
</div>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Interpolation</span>
<div className="grid grid-cols-3 gap-1">
{(['nearest', 'bilinear', 'bicubic'] as const).map((interp) => (
<button
key={interp}
onClick={() => setTransformSettings({ interpolation: interp })}
className={`px-2 py-1.5 text-xs rounded transition-colors capitalize ${
transformSettings.interpolation === interp
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{interp}
</button>
))}
</div>
</div>
<div className="pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={transformSettings.maintainAspectRatio}
onChange={(e) => setTransformSettings({ maintainAspectRatio: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Maintain Aspect Ratio (Shift)
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,440 @@
import { useState, useRef, useEffect } from 'react';
import { Eye, EyeOff, Lock, Unlock, Trash2, Copy, ChevronUp, ChevronDown, ArrowUp, ArrowDown, ArrowUpToLine, ArrowDownToLine, Clipboard, ClipboardCopy, Scissors, Paintbrush, Search, X, Image, Type, Hexagon, Folder, FolderPlus, FolderOpen } from 'lucide-react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer, LayerType } from '../../../types/project';
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuCheckboxItem,
Slider,
} from '@openreel/ui';
type FilterType = 'all' | LayerType;
const LAYER_TYPE_ICONS: Record<LayerType, React.ReactNode> = {
image: <Image size={12} />,
text: <Type size={12} />,
shape: <Hexagon size={12} />,
group: <Folder size={12} />,
'smart-object': <FolderOpen size={12} />,
};
export function LayerPanel() {
const {
project,
selectedLayerIds,
selectedArtboardId,
copiedStyle,
selectLayer,
selectLayers,
updateLayer,
updateLayerTransform,
removeLayer,
duplicateLayer,
moveLayerUp,
moveLayerDown,
moveLayerToTop,
moveLayerToBottom,
copyLayers,
cutLayers,
pasteLayers,
copyLayerStyle,
pasteLayerStyle,
groupLayers,
ungroupLayers,
} = useProjectStore();
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<FilterType>('all');
const [editingLayerId, setEditingLayerId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const editInputRef = useRef<HTMLInputElement>(null);
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
const allLayers = artboard?.layerIds.map((id) => project?.layers[id]).filter(Boolean) as Layer[] ?? [];
const layers = allLayers.filter((layer) => {
const matchesSearch = searchQuery === '' || layer.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = filterType === 'all' || layer.type === filterType;
return matchesSearch && matchesType;
});
const handleSelectAllByType = (type: LayerType) => {
const layerIds = allLayers.filter((l) => l.type === type).map((l) => l.id);
if (layerIds.length > 0) {
selectLayers(layerIds);
}
};
const handleStartRename = (layer: Layer) => {
setEditingLayerId(layer.id);
setEditingName(layer.name);
};
const handleFinishRename = () => {
if (editingLayerId && editingName.trim()) {
updateLayer(editingLayerId, { name: editingName.trim() });
}
setEditingLayerId(null);
setEditingName('');
};
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleFinishRename();
} else if (e.key === 'Escape') {
setEditingLayerId(null);
setEditingName('');
}
};
useEffect(() => {
if (editingLayerId && editInputRef.current) {
editInputRef.current.focus();
editInputRef.current.select();
}
}, [editingLayerId]);
const handleToggleVisibility = (layer: Layer, e: React.MouseEvent) => {
e.stopPropagation();
updateLayer(layer.id, { visible: !layer.visible });
};
const handleToggleLock = (layer: Layer, e: React.MouseEvent) => {
e.stopPropagation();
updateLayer(layer.id, { locked: !layer.locked });
};
const handleDelete = (layerId: string, e: React.MouseEvent) => {
e.stopPropagation();
removeLayer(layerId);
};
const handleDuplicate = (layerId: string, e: React.MouseEvent) => {
e.stopPropagation();
duplicateLayer(layerId);
};
const getLayerIcon = (type: Layer['type']) => {
switch (type) {
case 'image':
return '🖼️';
case 'text':
return 'T';
case 'shape':
return '◆';
case 'group':
return '📁';
default:
return '•';
}
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<h3 className="text-xs font-medium text-foreground">Layers</h3>
<span className="text-[10px] text-muted-foreground">
{layers.length}/{allLayers.length}
</span>
</div>
<div className="px-2 py-2 border-b border-border space-y-2">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search layers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-7 pr-7 py-1.5 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<div className="flex gap-1">
<button
onClick={() => setFilterType('all')}
className={`flex-1 px-1.5 py-1 text-[10px] rounded transition-colors ${
filterType === 'all' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
All
</button>
{(['image', 'text', 'shape', 'group', 'smart-object'] as LayerType[]).map((type) => (
<button
key={type}
onClick={() => setFilterType(filterType === type ? 'all' : type)}
onDoubleClick={() => handleSelectAllByType(type)}
aria-label={`Filter ${type} layers`}
className={`p-1.5 rounded transition-colors ${
filterType === type ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title={`Filter ${type}s (double-click to select all)`}
>
{LAYER_TYPE_ICONS[type]}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{layers.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<p className="text-xs text-muted-foreground">No layers yet</p>
<p className="text-[10px] text-muted-foreground mt-1">
Add text, shapes, or images
</p>
</div>
) : (
<div className="py-1">
{layers.map((layer) => {
const isSelected = selectedLayerIds.includes(layer.id);
return (
<ContextMenu key={layer.id}>
<ContextMenuTrigger asChild>
<div
onClick={() => selectLayer(layer.id)}
className={`group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors ${
isSelected
? 'bg-primary/20 border-l-2 border-primary'
: 'hover:bg-accent border-l-2 border-transparent'
}`}
>
<span
className={`w-5 h-5 flex items-center justify-center text-xs rounded ${
layer.type === 'text' ? 'font-bold' : ''
}`}
>
{getLayerIcon(layer.type)}
</span>
{editingLayerId === layer.id ? (
<input
ref={editInputRef}
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={handleRenameKeyDown}
onClick={(e) => e.stopPropagation()}
className="flex-1 text-xs bg-background border border-primary rounded px-1 py-0.5 focus:outline-none"
/>
) : (
<span
onDoubleClick={(e) => {
e.stopPropagation();
handleStartRename(layer);
}}
className={`flex-1 text-xs truncate ${
layer.visible ? 'text-foreground' : 'text-muted-foreground'
} ${layer.locked ? 'italic' : ''}`}
title="Double-click to rename"
>
{layer.name}
</span>
)}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleToggleVisibility(layer, e)}
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title={layer.visible ? 'Hide' : 'Show'}
>
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
<button
onClick={(e) => handleToggleLock(layer, e)}
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title={layer.locked ? 'Unlock' : 'Lock'}
>
{layer.locked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
<button
onClick={(e) => handleDuplicate(layer.id, e)}
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title="Duplicate"
>
<Copy size={12} />
</button>
<button
onClick={(e) => handleDelete(layer.id, e)}
className="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => { selectLayer(layer.id); copyLayers(); }}>
<ClipboardCopy size={14} className="mr-2" />
Copy
<ContextMenuShortcut>C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => { selectLayer(layer.id); cutLayers(); }}>
<Scissors size={14} className="mr-2" />
Cut
<ContextMenuShortcut>X</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={pasteLayers}>
<Clipboard size={14} className="mr-2" />
Paste
<ContextMenuShortcut>V</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateLayer(layer.id)}>
<Copy size={14} className="mr-2" />
Duplicate
<ContextMenuShortcut>D</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
{selectedLayerIds.length > 1 && (
<ContextMenuItem onClick={() => groupLayers(selectedLayerIds)}>
<FolderPlus size={14} className="mr-2" />
Group Selection
<ContextMenuShortcut>G</ContextMenuShortcut>
</ContextMenuItem>
)}
{layer.type === 'group' && (
<ContextMenuItem onClick={() => ungroupLayers(layer.id)}>
<FolderOpen size={14} className="mr-2" />
Ungroup
<ContextMenuShortcut>G</ContextMenuShortcut>
</ContextMenuItem>
)}
{(selectedLayerIds.length > 1 || layer.type === 'group') && <ContextMenuSeparator />}
<ContextMenuItem onClick={() => { selectLayer(layer.id); copyLayerStyle(); }}>
<Paintbrush size={14} className="mr-2" />
Copy Style
</ContextMenuItem>
<ContextMenuItem onClick={pasteLayerStyle} disabled={!copiedStyle}>
<Paintbrush size={14} className="mr-2" />
Paste Style
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => moveLayerToTop(layer.id)}>
<ArrowUpToLine size={14} className="mr-2" />
Bring to Front
<ContextMenuShortcut>]</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => moveLayerUp(layer.id)}>
<ArrowUp size={14} className="mr-2" />
Bring Forward
<ContextMenuShortcut>]</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => moveLayerDown(layer.id)}>
<ArrowDown size={14} className="mr-2" />
Send Backward
<ContextMenuShortcut>[</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => moveLayerToBottom(layer.id)}>
<ArrowDownToLine size={14} className="mr-2" />
Send to Back
<ContextMenuShortcut>[</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={layer.visible}
onCheckedChange={() => updateLayer(layer.id, { visible: !layer.visible })}
>
{layer.visible ? <Eye size={14} className="mr-2" /> : <EyeOff size={14} className="mr-2" />}
Visible
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
checked={layer.locked}
onCheckedChange={() => updateLayer(layer.id, { locked: !layer.locked })}
>
{layer.locked ? <Lock size={14} className="mr-2" /> : <Unlock size={14} className="mr-2" />}
Locked
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => removeLayer(layer.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
Delete
<ContextMenuShortcut></ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
)}
</div>
{selectedLayerIds.length > 1 && (
<div className="p-2 border-t border-border">
<button
onClick={() => groupLayers(selectedLayerIds)}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors"
>
<FolderPlus size={14} />
Group {selectedLayerIds.length} Layers
</button>
</div>
)}
{selectedLayerIds.length === 1 && (
<div className="p-2 border-t border-border space-y-2">
{project?.layers[selectedLayerIds[0]]?.type === 'group' && (
<button
onClick={() => ungroupLayers(selectedLayerIds[0])}
className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-secondary text-secondary-foreground text-xs font-medium hover:bg-secondary/80 transition-colors mb-2"
>
<FolderOpen size={14} />
Ungroup
</button>
)}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground w-12">Opacity</span>
<Slider
value={[project?.layers[selectedLayerIds[0]]?.transform.opacity ?? 1]}
onValueChange={([opacity]) => updateLayerTransform(selectedLayerIds[0], { opacity })}
min={0}
max={1}
step={0.01}
className="flex-1"
/>
<span className="text-[10px] text-muted-foreground w-8 text-right">
{Math.round((project?.layers[selectedLayerIds[0]]?.transform.opacity ?? 1) * 100)}%
</span>
</div>
<div className="flex items-center justify-center gap-1">
<button
onClick={() => moveLayerUp(selectedLayerIds[0])}
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
title="Move up (Cmd+])"
>
<ChevronUp size={14} />
</button>
<button
onClick={() => moveLayerDown(selectedLayerIds[0])}
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
title="Move down (Cmd+[)"
>
<ChevronDown size={14} />
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,199 @@
import { useState, useRef } from 'react';
import { Plus, Trash2, Copy, MoreHorizontal, ChevronUp, ChevronDown } from 'lucide-react';
import { useProjectStore } from '../../../stores/project-store';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@openreel/ui';
export function PagesBar() {
const {
project,
selectedArtboardId,
selectArtboard,
addArtboard,
removeArtboard,
updateArtboard,
} = useProjectStore();
const [isExpanded, setIsExpanded] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
if (!project) return null;
const artboards = project.artboards;
const handleAddPage = () => {
const currentArtboard = artboards.find((a) => a.id === selectedArtboardId);
const size = currentArtboard?.size ?? { width: 1080, height: 1080 };
const newId = addArtboard(`Page ${artboards.length + 1}`, size);
selectArtboard(newId);
};
const handleDuplicatePage = (artboardId: string) => {
const artboard = artboards.find((a) => a.id === artboardId);
if (!artboard) return;
const newId = addArtboard(`${artboard.name} copy`, artboard.size);
selectArtboard(newId);
};
const handleDeletePage = (artboardId: string) => {
if (artboards.length <= 1) return;
removeArtboard(artboardId);
};
const handleRename = (artboardId: string, newName: string) => {
if (newName.trim()) {
updateArtboard(artboardId, { name: newName.trim() });
}
setEditingId(null);
};
const handleStartRename = (artboardId: string) => {
setEditingId(artboardId);
setTimeout(() => inputRef.current?.select(), 0);
};
return (
<div className="bg-card border-t border-border">
<div className="flex items-center justify-between px-3 py-1.5">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
<span>Pages</span>
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded-full">
{artboards.length}
</span>
</button>
<button
onClick={handleAddPage}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<Plus size={14} />
<span>Add Page</span>
</button>
</div>
{isExpanded && (
<div className="px-3 pb-3 overflow-x-auto">
<div className="flex gap-2">
{artboards.map((artboard) => {
const isSelected = artboard.id === selectedArtboardId;
const aspectRatio = artboard.size.width / artboard.size.height;
const thumbHeight = 64;
const thumbWidth = Math.min(thumbHeight * aspectRatio, 100);
return (
<div
key={artboard.id}
className={`group relative flex-shrink-0 rounded-lg border-2 transition-all cursor-pointer ${
isSelected
? 'border-primary ring-2 ring-primary/20'
: 'border-border hover:border-muted-foreground'
}`}
onClick={() => selectArtboard(artboard.id)}
>
<div
className="bg-muted rounded-md flex items-center justify-center overflow-hidden"
style={{ width: thumbWidth, height: thumbHeight }}
>
<div
className="rounded-sm"
style={{
width: thumbWidth - 8,
height: thumbHeight - 8,
backgroundColor:
artboard.background.type === 'color'
? artboard.background.color
: artboard.background.type === 'transparent'
? 'transparent'
: '#ffffff',
backgroundImage:
artboard.background.type === 'transparent'
? 'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)'
: undefined,
backgroundSize: '8px 8px',
backgroundPosition: '0 0, 0 4px, 4px -4px, -4px 0px',
}}
/>
</div>
<div className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="w-5 h-5 flex items-center justify-center bg-background border border-border rounded shadow-sm hover:bg-accent transition-colors"
>
<MoreHorizontal size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleStartRename(artboard.id)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicatePage(artboard.id)}>
<Copy size={14} className="mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeletePage(artboard.id)}
disabled={artboards.length <= 1}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="absolute -bottom-5 left-0 right-0 text-center">
{editingId === artboard.id ? (
<input
ref={inputRef}
type="text"
defaultValue={artboard.name}
onBlur={(e) => handleRename(artboard.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename(artboard.id, e.currentTarget.value);
} else if (e.key === 'Escape') {
setEditingId(null);
}
}}
onClick={(e) => e.stopPropagation()}
className="w-full text-[10px] text-center bg-transparent border-none focus:outline-none focus:ring-1 focus:ring-primary rounded px-1"
autoFocus
/>
) : (
<span
className={`text-[10px] truncate max-w-full inline-block ${
isSelected ? 'text-foreground font-medium' : 'text-muted-foreground'
}`}
onDoubleClick={(e) => {
e.stopPropagation();
handleStartRename(artboard.id);
}}
>
{artboard.name}
</span>
)}
</div>
</div>
);
})}
<button
onClick={handleAddPage}
className="flex-shrink-0 w-16 h-16 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border hover:border-muted-foreground hover:bg-accent/50 transition-all"
>
<Plus size={20} className="text-muted-foreground" />
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,290 @@
import { useState } from 'react';
import { Ruler, Plus, Trash2, X, ArrowRight, ArrowDown } from 'lucide-react';
import { useCanvasStore, type Guide } from '../../../stores/canvas-store';
import { useProjectStore } from '../../../stores/project-store';
export function GuidePanel() {
const { guides, addGuide, removeGuide, updateGuide, clearGuides } = useCanvasStore();
const { project, selectedArtboardId } = useProjectStore();
const [editingGuideId, setEditingGuideId] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const [newGuideType, setNewGuideType] = useState<'horizontal' | 'vertical'>('horizontal');
const [newGuidePosition, setNewGuidePosition] = useState('');
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
const horizontalGuides = guides.filter((g) => g.type === 'horizontal');
const verticalGuides = guides.filter((g) => g.type === 'vertical');
const handleAddGuide = () => {
const position = parseFloat(newGuidePosition);
if (!isNaN(position)) {
addGuide(newGuideType, position);
setNewGuidePosition('');
setShowAddForm(false);
}
};
const handleStartEdit = (guide: Guide) => {
setEditingGuideId(guide.id);
setEditingValue(guide.position.toString());
};
const handleFinishEdit = () => {
if (editingGuideId) {
const position = parseFloat(editingValue);
if (!isNaN(position)) {
updateGuide(editingGuideId, position);
}
}
setEditingGuideId(null);
setEditingValue('');
};
const handleAddCenterGuides = () => {
if (artboard) {
addGuide('horizontal', artboard.size.height / 2);
addGuide('vertical', artboard.size.width / 2);
}
};
const handleAddThirdsGuides = () => {
if (artboard) {
addGuide('horizontal', artboard.size.height / 3);
addGuide('horizontal', (artboard.size.height * 2) / 3);
addGuide('vertical', artboard.size.width / 3);
addGuide('vertical', (artboard.size.width * 2) / 3);
}
};
const handleAddEdgeGuides = () => {
if (artboard) {
const margin = Math.min(artboard.size.width, artboard.size.height) * 0.1;
addGuide('horizontal', margin);
addGuide('horizontal', artboard.size.height - margin);
addGuide('vertical', margin);
addGuide('vertical', artboard.size.width - margin);
}
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="flex items-center gap-2">
<Ruler size={14} className="text-muted-foreground" />
<h3 className="text-xs font-medium text-foreground">Guides</h3>
</div>
<span className="text-[10px] text-muted-foreground">{guides.length}</span>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-2 space-y-3">
<div className="flex gap-1">
<button
onClick={() => setShowAddForm(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-primary text-primary-foreground text-[10px] font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={12} />
Add Guide
</button>
{guides.length > 0 && (
<button
onClick={clearGuides}
className="p-1.5 rounded-md bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
title="Clear all guides"
>
<Trash2 size={14} />
</button>
)}
</div>
{showAddForm && (
<div className="p-2 bg-secondary/50 rounded-lg space-y-2">
<div className="flex gap-1">
<button
onClick={() => setNewGuideType('horizontal')}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded text-[10px] transition-colors ${
newGuideType === 'horizontal'
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground'
}`}
>
<ArrowRight size={10} />
Horizontal
</button>
<button
onClick={() => setNewGuideType('vertical')}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded text-[10px] transition-colors ${
newGuideType === 'vertical'
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground'
}`}
>
<ArrowDown size={10} />
Vertical
</button>
</div>
<div className="flex gap-1">
<input
type="number"
value={newGuidePosition}
onChange={(e) => setNewGuidePosition(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddGuide();
if (e.key === 'Escape') setShowAddForm(false);
}}
placeholder={newGuideType === 'horizontal' ? 'Y position...' : 'X position...'}
className="flex-1 px-2 py-1 text-[10px] bg-background border border-input rounded focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
/>
<button
onClick={handleAddGuide}
className="px-2 py-1 rounded bg-primary text-primary-foreground text-[10px] hover:bg-primary/90 transition-colors"
>
Add
</button>
<button
onClick={() => setShowAddForm(false)}
className="p-1 rounded bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
>
<X size={12} />
</button>
</div>
</div>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground font-medium">Quick Presets</span>
<div className="grid grid-cols-3 gap-1">
<button
onClick={handleAddCenterGuides}
className="px-2 py-1.5 rounded bg-secondary/50 text-[9px] text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Center
</button>
<button
onClick={handleAddThirdsGuides}
className="px-2 py-1.5 rounded bg-secondary/50 text-[9px] text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Thirds
</button>
<button
onClick={handleAddEdgeGuides}
className="px-2 py-1.5 rounded bg-secondary/50 text-[9px] text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Margins
</button>
</div>
</div>
{horizontalGuides.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<ArrowRight size={10} className="text-blue-400" />
<span className="text-[10px] text-muted-foreground">
Horizontal ({horizontalGuides.length})
</span>
</div>
<div className="space-y-0.5">
{horizontalGuides.map((guide) => (
<div
key={guide.id}
className="flex items-center gap-1 px-2 py-1 rounded bg-blue-500/10 group"
>
<span className="text-[10px] text-blue-400 w-4">Y</span>
{editingGuideId === guide.id ? (
<input
type="number"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onBlur={handleFinishEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishEdit();
if (e.key === 'Escape') setEditingGuideId(null);
}}
className="flex-1 px-1 py-0.5 text-[10px] bg-background border border-primary rounded focus:outline-none"
autoFocus
/>
) : (
<button
onClick={() => handleStartEdit(guide)}
className="flex-1 text-left text-[10px] text-foreground hover:text-primary transition-colors"
>
{Math.round(guide.position)}px
</button>
)}
<button
onClick={() => removeGuide(guide.id)}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-all"
>
<X size={10} />
</button>
</div>
))}
</div>
</div>
)}
{verticalGuides.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<ArrowDown size={10} className="text-green-400" />
<span className="text-[10px] text-muted-foreground">
Vertical ({verticalGuides.length})
</span>
</div>
<div className="space-y-0.5">
{verticalGuides.map((guide) => (
<div
key={guide.id}
className="flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 group"
>
<span className="text-[10px] text-green-400 w-4">X</span>
{editingGuideId === guide.id ? (
<input
type="number"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onBlur={handleFinishEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishEdit();
if (e.key === 'Escape') setEditingGuideId(null);
}}
className="flex-1 px-1 py-0.5 text-[10px] bg-background border border-primary rounded focus:outline-none"
autoFocus
/>
) : (
<button
onClick={() => handleStartEdit(guide)}
className="flex-1 text-left text-[10px] text-foreground hover:text-primary transition-colors"
>
{Math.round(guide.position)}px
</button>
)}
<button
onClick={() => removeGuide(guide.id)}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-all"
>
<X size={10} />
</button>
</div>
))}
</div>
</div>
)}
{guides.length === 0 && !showAddForm && (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Ruler size={24} className="text-muted-foreground/40 mb-2" />
<p className="text-[10px] text-muted-foreground">No guides yet</p>
<p className="text-[9px] text-muted-foreground/60 mt-0.5">
Click "Add Guide" or use presets
</p>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,275 @@
import { useState } from 'react';
import {
History,
Undo2,
Redo2,
Trash2,
Clock,
Camera,
Bookmark,
ChevronDown,
ChevronRight,
Edit2,
Check,
X,
} from 'lucide-react';
import { useHistoryStore } from '../../../stores/history-store';
import { useProjectStore } from '../../../stores/project-store';
import { formatDistanceToNow } from '../../../utils/time';
export function HistoryPanel() {
const entries = useHistoryStore((s) => s.getEntries());
const currentIndex = useHistoryStore((s) => s.getCurrentIndex());
const snapshots = useHistoryStore((s) => s.getSnapshots());
const clear = useHistoryStore((s) => s.clear);
const canUndo = useHistoryStore((s) => s.canUndo);
const canRedo = useHistoryStore((s) => s.canRedo);
const createSnapshot = useHistoryStore((s) => s.createSnapshot);
const restoreSnapshot = useHistoryStore((s) => s.restoreSnapshot);
const deleteSnapshot = useHistoryStore((s) => s.deleteSnapshot);
const renameSnapshot = useHistoryStore((s) => s.renameSnapshot);
const goToEntry = useHistoryStore((s) => s.goToEntry);
const { project, loadProject, undo, redo } = useProjectStore();
const [showSnapshots, setShowSnapshots] = useState(true);
const [editingSnapshotId, setEditingSnapshotId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const handleUndo = () => {
undo();
};
const handleRedo = () => {
redo();
};
const handleJumpToState = (index: number) => {
if (index === currentIndex) return;
const state = goToEntry(index);
if (state) {
loadProject(state);
}
};
const handleCreateSnapshot = () => {
if (!project) return;
const name = `Snapshot ${snapshots.length + 1}`;
createSnapshot(name, project);
};
const handleRestoreSnapshot = (id: string) => {
const state = restoreSnapshot(id);
if (state) {
loadProject(state);
}
};
const handleStartRename = (id: string, currentName: string) => {
setEditingSnapshotId(id);
setEditingName(currentName);
};
const handleSaveRename = () => {
if (editingSnapshotId && editingName.trim()) {
renameSnapshot(editingSnapshotId, editingName.trim());
}
setEditingSnapshotId(null);
setEditingName('');
};
const handleCancelRename = () => {
setEditingSnapshotId(null);
setEditingName('');
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between p-3 border-b border-border">
<div className="flex items-center gap-2">
<History size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">History</h3>
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded-full text-muted-foreground">
{entries.length}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleUndo}
disabled={!canUndo()}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
</button>
<button
onClick={handleRedo}
disabled={!canRedo()}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
title="Redo (Ctrl+Shift+Z)"
>
<Redo2 size={14} />
</button>
<button
onClick={() => clear(project ?? undefined)}
disabled={entries.length === 0}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
title="Clear history"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="border-b border-border">
<button
onClick={() => setShowSnapshots(!showSnapshots)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-accent/50 transition-colors"
>
{showSnapshots ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Bookmark size={12} className="text-muted-foreground" />
<span className="text-[11px] font-medium">Snapshots</span>
<span className="text-[10px] text-muted-foreground ml-auto">{snapshots.length}</span>
</button>
{showSnapshots && (
<div className="px-2 pb-2">
<button
onClick={handleCreateSnapshot}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 mb-2 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<Camera size={10} />
New Snapshot
</button>
{snapshots.length === 0 ? (
<p className="text-[10px] text-muted-foreground text-center py-2">
No snapshots yet
</p>
) : (
<div className="space-y-1">
{snapshots.map((snapshot) => (
<div
key={snapshot.id}
className="group flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent/50 transition-colors"
>
{editingSnapshotId === snapshot.id ? (
<div className="flex-1 flex items-center gap-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveRename();
if (e.key === 'Escape') handleCancelRename();
}}
className="flex-1 px-1 py-0.5 text-[10px] bg-background border border-border rounded"
autoFocus
/>
<button
onClick={handleSaveRename}
className="p-0.5 text-green-500 hover:text-green-400"
>
<Check size={10} />
</button>
<button
onClick={handleCancelRename}
className="p-0.5 text-muted-foreground hover:text-foreground"
>
<X size={10} />
</button>
</div>
) : (
<>
<button
onClick={() => handleRestoreSnapshot(snapshot.id)}
className="flex-1 text-left"
>
<p className="text-[10px] font-medium truncate">{snapshot.name}</p>
<p className="text-[9px] text-muted-foreground">
{formatDistanceToNow(snapshot.timestamp)}
</p>
</button>
<div className="hidden group-hover:flex items-center gap-0.5">
<button
onClick={() => handleStartRename(snapshot.id, snapshot.name)}
className="p-0.5 text-muted-foreground hover:text-foreground"
>
<Edit2 size={10} />
</button>
<button
onClick={() => deleteSnapshot(snapshot.id)}
className="p-0.5 text-muted-foreground hover:text-destructive"
>
<Trash2 size={10} />
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
{entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Clock size={32} className="text-muted-foreground/50 mb-3" />
<p className="text-xs text-muted-foreground">No history yet</p>
<p className="text-[10px] text-muted-foreground/70 mt-1">
Your actions will appear here
</p>
</div>
) : (
<div className="py-1">
{[...entries].reverse().map((entry, reverseIndex) => {
const index = entries.length - 1 - reverseIndex;
const isCurrent = index === currentIndex;
const isFuture = index > currentIndex;
return (
<button
key={entry.id}
onClick={() => handleJumpToState(index)}
className={`w-full flex items-start gap-2 px-3 py-2 text-left transition-colors ${
isCurrent
? 'bg-primary/10 border-l-2 border-primary'
: isFuture
? 'opacity-50 hover:opacity-75 hover:bg-accent/50'
: 'hover:bg-accent'
}`}
>
<div
className={`mt-0.5 w-2 h-2 rounded-full flex-shrink-0 ${
isCurrent
? 'bg-primary'
: isFuture
? 'bg-muted-foreground/30'
: 'bg-muted-foreground/50'
}`}
/>
<div className="min-w-0 flex-1">
<p
className={`text-xs truncate ${
isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground'
}`}
>
{entry.description}
</p>
<p className="text-[9px] text-muted-foreground/70">
{formatDistanceToNow(entry.timestamp)}
</p>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}

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