Compare commits

..

646 commits

Author SHA1 Message Date
43656a5e88 shell: youtube nav icon: download → import
download is used by the Downloads section tile on the home screen.
YouTube ingest gets the new import icon (arrow entering box) instead.
2026-05-31 10:52:10 -04:00
68461af990 icons: add import icon (arrow entering box)
Distinct from download (vertical arrow+line). Used for YouTube ingest
to avoid sharing a glyph with the Downloads section tile.
2026-05-31 10:50:43 -04:00
8bc460025d screens-home: fix launcher tile icons
- Dashboard tile: home → layout (matches sidebar nav icon)
- Playout tile: monitor → signal (matches sidebar nav fix)
2026-05-31 00:19:34 -04:00
3578c7b4e9 fix(playout): Privileged only for decklink (SRT/NDI/RTMP/HLS crashed when GPU exposed without driver) 2026-05-30 18:59:27 -04:00
cddcc9a29e fix(mam-api): selfHeartbeat writes last_seen_at so primary node isn't stale-failover-killed 2026-05-30 18:57:20 -04:00
0e844c0fc3 fix(scheduler): use updated_at as grace anchor when last_heartbeat_at NULL
Without this, a freshly-spawned channel with NULL last_heartbeat_at was
instantly failover-killed by the playoutHealthTick because `0` was used as
the lastSeen timestamp, making ageMs huge on the very first tick.
2026-05-30 17:32:15 -04:00
551af09dc7 fix(playout): install libnss3 so CEF can init (NSS -8023 was killing the channel ~30s in) 2026-05-30 17:16:54 -04:00
4d6a999665 fix(playout): pre-create NSS dir + CEF cache so CEF/HTML producer doesn't SIGABRT 2026-05-30 17:14:07 -04:00
f971d57bb9 fix(playout): use unzip not python zipfile (preserves exec bits) 2026-05-30 17:00:25 -04:00
7ab70948a0 fix(playout): entrypoint handles 2.4.x bin/casparcg layout + LD_LIBRARY_PATH for bundled libs 2026-05-30 16:50:04 -04:00
13bbd4216e fix(playout): correct 2.4.0 zip layout — binary is at casparcg_server/bin/casparcg 2026-05-30 16:49:48 -04:00
fcd8e8dd2e fix(playout): entrypoint finds binary in /opt/casparcg for 2.4.x tarball layout 2026-05-30 16:44:23 -04:00
67ac007706 fix(playout): downgrade CasparCG to 2.4.0 ubuntu22 zip (2.5 requires AVX2, ZAMPP has AVX only) 2026-05-30 16:44:07 -04:00
b4f2fb12ff fix(mam-api): heartbeat writes last_seen_at so playout failover sees healthy nodes 2026-05-30 16:32:11 -04:00
aa7f836493 fix(playout): strip XML comments from casparcg.config (2.5 rejects them) 2026-05-30 16:30:54 -04:00
c2409bd037 fix(mam-api): add last_seen_at to cluster_nodes for playout failover
Playout failover queries cluster_nodes.last_seen_at to find healthy nodes
for channel re-placement. Column missing from original cluster schema.

Migration 031 adds column + backfills existing nodes to NOW().

Fixes scheduler error: column "last_seen_at" does not exist

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 13:39:06 -04:00
42064acefa shell: fix nav icon conflicts
- schedule: jobs → clock (was sharing hamburger icon with Jobs)
- playout: monitor → signal (was sharing TV icon with Monitors)
2026-05-30 13:30:42 -04:00
2e2b091653 icons: fix 4 icon issues found in audit
- jobs: replace hamburger (nav menu) with bulleted list (task queue)
- grid: add rx="1" to match library icon (consistency)
- hdd: replace circle+dot (vinyl) with cylinder (storage)
- proxy: replace upload-arrow with sliders (transcode/transform)
2026-05-30 13:29:18 -04:00
Zac
c502d4a16f feat(web-ui): update home tagline + add "Let's create" motto
Tagline "Self-hosted broadcast media-asset management" ->
"Media Asset Management & Production Platform"; add italic accent motto
"Let's create" below it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:04:28 +00:00
Zac
9d098e9778 feat(auth-ui): interactive permissions matrix, admin 2FA reset, Downloads button
Backend (routes/users.js):
- GET / now returns totp_enabled so the UI can show 2FA status
- GET /:id/access — admin-only effective per-project access (MAX over direct +
  group grants), labels via=direct|group:<name>; admins report all/edit
- POST /:id/totp/disable — admin clears a locked-out user's 2FA without their
  password (self-service disable still requires it); dev user blocked
- role validated against {admin,editor,viewer} on create + PATCH (was unchecked)

Frontend:
- Users>Policies tab: static prose replaced with interactive per-user matrix —
  inline role select, 2FA badge, Reset-2FA action, lazy per-user access expander
- Home "Premiere panel" tile -> "Downloads"; modal renamed, adds Teams ISO row
  (disabled "coming soon" until the .exe is supplied); UXP .ccx link unchanged
- data.jsx: window.TEAMS_ISO placeholder ({available:false})

Not runtime-tested in browser yet. Teams ISO .exe still pending from user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:59:27 +00:00
Zac
02631f7b96 fix(playout): locate CasparCG 2.5 binary at /usr/bin/casparcg-server-2.5
First 2.5 build got past the deb install but the binary-discovery step
produced an empty $BIN (test -n failed): the 2.5 deb names its executable
casparcg-server-2.5, which the old case pattern (*/casparcg, */CasparCG
Server) didn't match. Broaden the match to /usr/bin/*casparcg*server*, fall
back to the known /usr/bin/casparcg-server-2.5, symlink it to
/usr/local/bin/casparcg, and make /opt/casparcg a real dir for our config
(no longer symlinked onto /usr/bin). Entrypoint launches `casparcg <config>`
from PATH instead of ./casparcg in a cwd.

Still NOT runtime-validated: 2.5 may reject the 2.3-era casparcg.config
schema (a bad config shows up as "Configuration file --version was not
found"); the deb ships a reference config at
/usr/share/casparcg-server-2.5/casparcg.config to diff against at smoke time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:34:02 +00:00
Zac
9436434599 fix(playout): build CasparCG 2.5.0 from .deb (2.3.3 tarball was a dead URL)
The image never built: CASPAR_URL pointed at a v2.3.3-stable Linux tarball
that CasparCG never published (2.3.x is Windows-only; Linux builds start at
2.4.0, and 2.4.1+ ship only as .deb). Rewrite to install the 2.5.0 noble
server + CEF debs on an ubuntu:24.04 base (Node 20 via nodesource), letting
apt resolve the GL/ffmpeg/openal runtime deps. Binary install dir is
discovered from the deb file list and symlinked to /opt/casparcg so the
entrypoint + config still run from there. Move CasparCG log/data dirs to
/media (writable mount) since the install dir may be read-only.

NOT runtime-validated: the 2.5 casparcg.config schema and the AMCP consumer
syntax (ADD <ch> STREAM/FILE) were authored against 2.3 and must be smoke-
tested against 2.5 before a channel start can be trusted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:31 +00:00
Zac
f837e57969 feat(web-ui): add Playout tile to home screen
Fetches /playout/channels separately and degrades silently when the
endpoint or schema is absent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:59:59 +00:00
Zac
ca71e47035 fix(playout): repair failover, authenticate scheduler self-calls, fix playlist walk + CasparCG consumer syntax
Post-review fixes for the 8-commit playout-mcr drop:

- Scheduler self-calls (callSelf -> /recorders, /playout) carried no auth, so
  under AUTH_ENABLED=true requireUiHeader 403'd every mutating POST. This broke
  playout failover AND scheduled recordings. Add a per-boot in-process service
  token (x-internal-token) the scheduler attaches; requireAuth/requireUiHeader
  treat it as the seeded admin. No env/compose config needed.

- Failover deadlocked: restartChannel set status='starting' then the scheduler
  called the guarded /start route, which 409s on 'starting'. Extract the spawn
  body into spawnChannelSidecar() shared by /start and restartChannel; failover
  now spawns directly with no self-call.

- Phase A playlist stalled after 2 clips: _scheduleAdvance cued the next clip
  via LOADBG AUTO but never advanced the pointer. Pass asset_duration_ms in the
  /play payload and arm a duration-based timer that advances currentIndex and
  cues subsequent clips, keeping as-run in sync for arbitrary-length playlists.

- CasparCG consumer syntax was invalid: "ADD <ch> FFMPEG" is the producer name,
  not a consumer keyword, and old -vcodec/-acodec short args are rejected. Use
  STREAM/FILE with -codec:v / -codec:a / -preset:v / -tune:v and a format=yuv420p
  filter ahead of libx264 (channel output is RGBA).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:51:35 +00:00
Zac
34352e3299 docs(playout): work log — commit map, decisions, testing checklist
Replaces the earlier aspirational "complete" log with the actual commit
sequence on feat/playout-mcr, the §7 decisions as built, the media-flow
diagram, port-contention + failover scope, and a runtime testing checklist
(migration → image build → SRT smoke → failover kill test).
2026-05-30 14:05:57 +00:00
Zac
d505a488ac build(playout): compose wiring + .env knobs
- Add /mnt/NVME/MAM/wild-dragon-media:/media to mam-api (rw) and worker-p4
  (rw); web-ui (ro, for serving HLS preview segments).
- worker-p4 WORKER_QUEUES gains 'playout-stage' so master-tier nodes pick up
  the loudnorm stage jobs (they already have ffmpeg + the media mount).
- New build-only 'playout' service with profile ["build-only"] so
  `docker compose --profile build-only build playout` produces the
  wild-dragon-playout:latest image without compose trying to up it as a
  long-running service. mam-api spawns these on demand.
- mam-api env adds PLAYOUT_IMAGE + PLAYOUT_AMCP_BASE_PORT (5250 default).
- .env.example: PLAYOUT_IMAGE, PLAYOUT_AMCP_BASE_PORT.
2026-05-30 14:05:57 +00:00
Zac
793011b78b feat(web-ui): MCR page — channels, playlist, transport, preview
screens-playout.jsx + styles-playout.css: program monitor (HLS preview from
the sidecar), media bin, drag-drop playlist editor, transport controls. Plain
HTML5 drag-drop, no extra library. Talks to /api/v1/playout via
ZAMPP_API.fetch.

Wired into the shell: "Playout" under Operations, breadcrumb mapping, route
case in app.jsx, stylesheet + dist/screens-playout.js script in index.html.
Format dropdown defaults to 1080p5994 (matches the new channel default).
2026-05-30 14:02:25 +00:00
Zac
5538683d78 feat(mam-api): /playout control plane + auto-failover
Routes: channel + playlist CRUD, start/stop/play/pause/skip transport, as-run
log. RBAC via assertProjectAccess on channel.project_id; null project ⇒
admin-only (recorder convention).

Sidecar orchestration mirrors recorders.js: Docker socket for local node,
node-agent /sidecar/start for remote. Channel start passes CHANNEL_ID env so
the sidecar can write HLS preview to /media/live/<id>.

DeckLink port-contention guard: blocks starting a decklink channel when a
recorder or another channel on the same node+device_index is active.

restartChannel(id) helper picks another healthy cluster node and re-places
non-decklink channels; decklink is alert-only. Exposed for the scheduler.

Scheduler tick adds step 6: poll each running channel's sidecar /status,
update last_heartbeat_at, and after ~3 misses trigger restartChannel +
self-call /start. Reuses the existing PG advisory lock so multi-replica
deploys don't double-fire failovers.
2026-05-30 14:02:25 +00:00
Zac
d62af34e98 feat(playout): CasparCG sidecar image + Node AMCP shim
One container per channel. Built like capture/build-with-decklink: NDI +
DeckLink SDKs fetched at build, runs --privileged with Xvfb for the GL
context where no real display is present.

Components:
- entrypoint.sh: Xvfb + CasparCG launch, creates /media/live/<CHANNEL_ID>
- src/amcp.js: TCP AMCP client
- src/playout-manager.js: channel lifecycle, playlist walk via LOADBG AUTO
  for gapless transitions; primary consumer (decklink/ndi/srt/rtmp) plus a
  second FFMPEG HLS consumer (~600 kbps, 2s segments) for the UI preview
- src/index.js: HTTP shim — /channel/start, /playlist/load, transport
- frame-rate helper picks fps from video_format (59.94 → 60000/1001) so
  SEEK / LENGTH frame math is correct
2026-05-30 14:02:25 +00:00
Zac
209f9fda52 feat(worker): playout-stage job — S3 → /media + EBU R128 loudnorm
Stages playlist items from S3 to the shared CasparCG media volume. Pass 1
measures, pass 2 applies linear loudnorm (I=-23 LUFS, TP=-1 dBTP, LRA=11);
output is AAC 192k @ 48 kHz, video stream copied. Atomic rename on success
so CasparCG never sees a partial file. Per-item audio_normalized flag means
re-stages of the same asset skip the loudnorm pass.

Wired into worker/src/index.js behind WORKER_QUEUES=playout-stage so
capability-routed deploys can pin it to nodes that already have ffmpeg +
the media mount.
2026-05-30 14:02:25 +00:00
Zac
29187a90df feat(mam-api): migration 029 — playout schema
Six tables: channels, playlists, items, sidecars (sidecar registry for
health-check), schedule (Phase B), as-run log.

- video_format default 1080p5994 (house standard, capture cadence)
- restart_count / last_restart_at / last_heartbeat_at on channels for
  auto-failover bookkeeping
- audio_normalized flag on items so re-stages skip the loudnorm pass
- unique partial index on (channel_id) for running sidecars
2026-05-30 14:02:25 +00:00
Zac
512267159a docs(playout): MCR design spec — Phase A playlist + Phase B 24/7
Single-doc design covering the playout subsystem: CasparCG-backed sidecars,
multi-channel placement, S3→/media staging, scheduling phases, the data
model, channel placement vs port contention.

§7 questions are answered inline (2026-05-30): −23 LUFS at stage time,
1080p5994 default, HLS preview v1, auto-restart-on-healthy-node failover
(DeckLink alert-only).
2026-05-30 14:02:25 +00:00
Zac
72fc608d8a fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.

1. TOTP brute-force gate (the big one). /login was calling
   ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
   *before* the second factor was proven. That cleared the per-IP failure
   counter, so each /login retry let an attacker with a known password
   hammer the 6-digit /login/totp space (10^6) at full speed.
   Now recordSuccess fires only inside establishSession() — i.e. after
   every required factor has actually passed (password [+TOTP] or
   OAuth [+TOTP]).

2. MFA ticket binding. Tickets issued by /login (and the Google callback)
   were unbound — a stolen ticket replayed from a different origin still
   worked. Tickets now carry SHA-256 hashes of the issuing request's IP
   and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
   even on mismatch so a wrong-binding probe can't be retried.

3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
   accepted the same code as many times as you submitted it. Now
   verifyToken returns the matched counter, and /login/totp does a CAS
   UPDATE on users.totp_last_counter — codes at counters <= the last
   accepted value are rejected. New migration 030 adds totp_last_counter,
   seeded on /totp/enable so the enrollment code itself can't be reused
   at first login, and zeroed on /totp/disable.

4. Google OAuth domain check no longer falls back to the email suffix
   when the hd (hosted-domain) claim is missing. Email-suffix matching
   let consumer (non-Workspace) Google accounts whose email happens to
   end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
   the operator means "only this Workspace", so accounts without a
   verified hd must be rejected.

Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:52:53 +00:00
Zac
3fe7d6bba2 fix(mam-api): close cross-project authz gaps in assets/bins/jobs/upload
Review of the v2 auth landing found four places where the per-project RBAC
helpers weren't applied to destination/source projects, letting a scoped
editor write into projects they don't have access to:

- assets PATCH /🆔 bin_id moved with no check, so an editor in project A
  could stuff their asset into a bin in project B. Now validates the bin's
  project_id matches the asset's own project (assets don't change project).
- assets POST /:id/copy: body's projectId/binId never checked, so any
  reachable asset could be cloned into an arbitrary project. Now asserts
  edit on the destination project and validates binId belongs there.
- bins POST /:id/assets: requireBinEdit checks edit on the bin's project but
  not on the source asset's project, so an asset from project B could be
  pulled into A's bin tree (and surfaced in A's views). Now the asset must
  belong to the bin's own project.
- jobs POST /conform: project_id from body never gated, so any logged-in
  user could enqueue conform jobs against any project. Now asserts edit.
- upload POST /init, POST /simple: projectId/binId from body never gated,
  same class of bug. Now asserts edit on projectId and validates binId.
- upload GET /: returned every in-progress upload globally, leaking
  filenames across projects. Now scoped via accessibleProjectIds.

These are the same pattern as the holes 2615143 closed on recorders/
sequences/imports/comments — these routes existed before the RBAC commit
landed and were never marked TODO(authz), so the broad sweep missed them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:52:29 +00:00
Zac
2615143c6d feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.

- recorders: param('id') resolves project + view baseline, requireRecorderEdit
  on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
  asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
  and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
  to write); also fixes the author bug (req.session.userId -> req.user.id, which
  was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
  registration) — TODO replaced with a rationale note

Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
  asset into a project the caller lacks edit on — now asserts edit on the
  override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
  could pull in (and via GET /:id leak signed proxy URLs for) assets from a
  project they can't access — now every clip asset must belong to the
  sequence's project; pre-transaction queries moved inside try/catch so a DB
  error returns 500 instead of hanging the request

- tests: recorders-access, sequences-access (incl. cross-project clip guard),
  comments-access (incl. author-id regression)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 03:48:02 +00:00
Zac
0c3a4b625f feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.

- migration 028: users.google_sub (unique) + email; password_hash nullable
  for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
  GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
  /auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
  friendly callback error handling
- .env.example documents all new vars

Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
  can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
  in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:51:59 +00:00
Zac
fff0828d79 feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.

- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
  recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
  login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
  /totp/disable (password-confirmed); login returns {mfa_required, ticket}
  when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
  manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:42:57 +00:00
Zac
ec026195eb feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.

- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
  assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
  view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
  "Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
  service-token-needs-admin/grants requirement

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:37:36 +00:00
9d6bbf8112 fix(mam-api): /stream returns MP4 url + separate hls_url (fixes Premiere import)
The HLS-VOD work made GET /assets/:id/stream return the HLS playlist URL as
`url` whenever hls_s3_key was set. The Premiere plugin's "Import Proxy"
downloads `url` to a file and imports it — so it was saving an .m3u8 playlist
as .mp4, and Premiere rejected it ("unsupported compression type"). This hit
every YouTube asset (all get HLS generated), regardless of codec.

/stream now returns the directly-downloadable MP4 proxy as `url` (type mp4)
and the HLS playlist as a separate `hls_url`. The web player prefers `hls_url`
(so in-browser HLS playback is unchanged), while the already-installed plugin
gets a real MP4 again — no plugin reinstall needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:44:52 -04:00
b449ef0ce3 fix(worker): YouTube importer prefers H.264 so originals import in Premiere
YouTube now packs AV1 inside .mp4, so the old `bv*[ext=mp4]` format selector
grabbed AV1 for the downloaded ORIGINAL. Premiere rejects AV1 on hi-res import
("unsupported file type"). The h264 proxy was fine, but the original wasn't.

Prefer avc1 (H.264) so the original is Premiere-native, falling back to the
previous any-mp4 behaviour only when no H.264 rendition exists. avc1 tops out
at 1080p on YouTube (above that is AV1/VP9 only), which is the universally
importable ceiling anyway.

Verified on a real URL: old selector -> av01 1080p; new selector -> avc1 1080p
(same resolution, now Premiere-native).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:21:44 -04:00
39ef551489 feat(uxp): ship the icon-rail panel redesign as v2.2.2 (recover from redesign branch)
The redesigned UXP panel (left icon rail, compact list-view toggle, hover
tooltips, single Export menu) was committed only to redesign/panel-icon-rail
and never merged, so main + the website kept serving the old blocky-button
build under the same version number (2.2.2). That branch had diverged off an
old main and is missing recent worker/HLS/NVENC/import work, so it can't be
merged wholesale — cherry-pick just the plugin instead.

- services/premiere-plugin-uxp: replace source with the redesigned panel
  (adds src/tooltip.js; reworks index.html + styles.css + src/*). Verified
  byte-identical to the build installed on BMG-PC-Edit.
- web-ui/public/downloads/dragonflight-mam-2.2.2.ccx: swap the served
  artifact to the redesigned 34708-byte build (download link unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:45:29 -04:00
8f26f1bd9a ui(web-ui): Projects above Library in nav + $ icon for Billing
- Reorder the Workspace nav group so Projects sits above Library.
- Add a lucide-style `dollar` icon and use it for the Billing nav item
  (was borrowing the `token` key icon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:17:42 -04:00
a7ef0397e1 fix(web-ui): show live download % on YouTube import bar
The import queue row's progress bar read only `r.progress`, which pollRow
never updated numerically — it tracked asset.status (ingesting/processing/
ready) but not a percentage, so the bar sat at 0 until the asset flipped to
ready, then snapped to 100 ("blank until it finishes").

pollRow now also fetches GET /jobs/<jobId> and feeds the BullMQ job's numeric
progress (worker emits 2..100 across the yt-dlp download + S3 upload) into the
bar, so it fills during the download. Falls back to status when the job is
evicted post-completion. Also reaffirm 'downloading' label while ingesting and
poll a bit faster (2s) since short clips finish quickly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:53:19 -04:00
cf1fe136d0 fix(worker): route import queue to a consumer + refresh stale yt-dlp
YouTube imports were stuck in `ingesting` forever: after the capability-
routing split (fdec2e3), every worker's WORKER_QUEUES enumerated an explicit
list that omitted `import`, so index.js's `want('import')` was false on every
worker and nothing consumed the import queue. Add `import` to worker-p4
(heavy/primary worker; import is concurrency-1 and network-bound, one consumer
is enough).

Second defect that would have surfaced once routed: the baked yt-dlp came from
the distro package (Ubuntu 22.04 apt = 2022.04.08, ~4 years stale), which
current YouTube rejects. Pull the latest self-contained yt-dlp release binary
at image build instead (GPU image: yt-dlp_linux; alpine image: python zipapp).
Rebuild the worker image to refresh.

Verified on zampp1: import jobs drain ingesting -> processing -> ready,
failed=0, yt-dlp 2026.03.17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:51:27 -04:00
0818f15498 fix(s3): land NodeHttpHandler request/connection timeout in main
The s3 client request-timeout fix (the original browser playback-hang fix)
was applied directly on zampp1 but never committed to main. Without it a
stalled RustFS GET hangs /video and /hls indefinitely. Landing it so a clean
deploy from main no longer regresses playback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:26:59 -04:00
4473427515 Merge remote-tracking branch 'wilddragon/feat/recorder-codec-bitrate' into integrate 2026-05-29 17:25:28 -04:00
9b47250388 feat(recorder): default All-Intra HEVC (NVENC) + custom bitrate, auto fps/res, source-bitrate warning
#2 Recorder codec/bitrate:
- Default recorder codec → hevc_nvenc (All-Intra HEVC NVENC); ProRes/H.264/DNxHR
  still selectable. recorders.js default flips prores_hq → hevc_nvenc.
- Custom target bitrate (Mbps) input, shown only for bitrate-controlled codecs
  (NVENC/x264/x265/DNxHD); ProRes shows quality-based (no bitrate).
- Framerate + resolution are auto-detected from source (manual fields removed).
- Container derived from codec (HEVC/ProRes/DNxHR → fragmented MOV, H.264 → MP4);
  drops the stub container picker (closes #150 direction).

#3 SRT/RTMP customization + bitrate warning:
- Same codec/bitrate/auto controls apply to network recorders (shared form).
- Warns in the modal when the configured target bitrate exceeds the probed
  source stream bitrate (via /recorders/probe) — re-encoding above source adds
  storage, not quality.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:04:00 -04:00
8ea750f5df feat(playback): HLS VOD rendition for browser (supplements MP4 proxy)
Browser playback of recorded assets moves to HLS, retiring the MP4
range-stitching path for VOD. MP4 proxy is kept for the Premiere panel.

- worker/hls.js: remuxToHls() stream-copies the proxy MP4 → fMP4 HLS
  (playlist.m3u8 + init.mp4 + segment_*.m4s) via existing segmentToHls,
  uploads to hls/<id>/, sets assets.hls_s3_key. hlsWorker backfills from
  an existing proxy.
- proxy.js: generate HLS inline after the MP4 upload (local file, no
  re-download, no re-encode); best-effort/non-fatal.
- worker/index.js: register 'hls' worker wherever 'proxy' runs.
- mam-api: GET /assets/:id/hls/:file serves playlist/init/segments as
  whole-object GETs (no Range → sidesteps RustFS bug), strict filename
  validation. /stream prefers hls_s3_key (type:'hls'). reprocess?type=hls
  backfills. Migration 025 adds assets.hls_s3_key.
- Frontend unchanged: hls.js path already handles type:'hls'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:18:15 -04:00
a28dc43ed5 docs: HLS VOD playback design (supplement MP4 proxy) 2026-05-29 16:13:29 -04:00
35fd9c0253 feat(web-ui): serve UXP (.ccx) plugin, remove legacy ZXP panel
Replaces the ZXP/CEP Premiere panel downloads with the UXP plugin:
- data.jsx PREMIERE_RELEASES → single UXP 2.2.2 entry (ccx pointer)
- home-page Premiere modal + Settings→Capture SDKs render the .ccx
  download and UXP install copy (UXP Developer Tool / Creative Cloud)
- add downloads/dragonflight-mam-2.2.2.ccx (built from premiere-plugin-uxp)
- remove the 1.0.0/1.0.1/1.1.0/1.2.0 .zxp + windows-setup.exe artifacts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:20:46 -04:00
0ee0cb91ef build(capture): nvenc-enabled ffmpeg Dockerfile (validated build)
Brings the node-local NVENC ffmpeg build into the branch and fixes it:
- multi-stage build with nv-codec-headers + --enable-nvenc/--enable-cuvid
- removes --pkg-config-flags="--static", which broke x265 detection
  ("ERROR: x265 not found using pkg-config") and prevented the image from
  ever building. Shared linking is used; runtime stage already apt-installs
  the shared codec libs (libx265-199 etc).
- self-validating: build fails if nvenc encoders are absent.

Rebuilt image confirmed to expose hevc_nvenc/h264_nvenc/av1_nvenc on the L4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:33:37 -04:00
9210b41589 fix(capture): working All-Intra HEVC NVENC config (validated on L4)
NVENC rejects -g 1 ("GopLength > numBFrames+1" at InitializeEncoder), so
true all-intra is achieved with -bf 0 -forced-idr 1 -g 600 plus
-force_key_frames expr:1 (forces an IDR on every frame). ffprobe confirms
all frames are pict_type=I. Container is fragmented MOV; MXF muxing of HEVC
fails on this ffmpeg build ("Operation not permitted").

Validated end-to-end via direct ffmpeg on zampp2/L4:
hevc, Main 10, 1920x1080, yuv420p10le, ptypes=[IIIIII...]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:23:44 -04:00
f2542bc929 feat(nvenc): GPU sidecar passthrough + All-Intra HEVC capture codec
Phase 0.2 of the NVENC All-Intra HEVC ingest plan.

node-agent/handleSidecarStart:
- Accept useGpu: true in the sidecar start body
- When useGpu: adds Runtime=nvidia, DeviceRequests=[gpu], and injects
  NVIDIA_VISIBLE_DEVICES=all + NVIDIA_DRIVER_CAPABILITIES=video,compute,utility
  into the container env. CPU-codec recorders are unaffected (useGpu defaults false).

mam-api/recorders (start endpoint):
- Derive useGpu from recorder.recording_codec — true for hevc_nvenc/h264_nvenc
- Pass useGpu to remote sidecar start body
- Apply same Runtime/DeviceRequests to the local Docker spawn path

capture/capture-manager:
- Update hevc_nvenc codec entry with all-intra flags:
  -g 1 -bf 0 (every frame IDR, no B-frames — required for growing-file
  edit-while-record), -rc vbr, -profile:v main10, pixFmt p010le (10-bit 4:2:0)

Next: validation gate (§8) — test MXF OP1a then fragmented MOV on one
DeckLink channel, mount in Premiere while recording.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:35:23 -04:00
0f6c715a30 docs: All-Intra HEVC (NVENC) growing-file ingest design
Captures the current working system (capture sidecar, finalize flow, live monitor, capability-routed GPU worker pool, deploy gotchas) and the target design: GPU All-Intra HEVC master to offload the ProRes CPU wall while keeping edit-while-record, scaling to 8 signals + multi-vendor (Blackmagic/Deltacast/AJA). Includes a validation gate (prove Premiere growing-HEVC edit on one channel first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 04:16:17 +00:00
fdec2e307d feat(worker): capability-routed GPU worker pool + per-node job attribution
WORKER_QUEUES env lets a worker subscribe to a subset of queues. Deploy one GPU-pinned container per card: heavy encodes (proxy/conform/trim) on Tesla P4 (zampp1) + L4 (zampp2) via NVENC; light jobs (thumbnail/filmstrip) on the 2x Quadro P400 (zampp1). BullMQ competing-consumers distribute across nodes. RUN_PROMOTION gates the growing-files scanner to one worker. Each worker stamps WORKER_LABEL onto job data so the Jobs UI Node column shows which node/GPU ran each job. Redis/DB/S3 for the zampp2 worker come from its .env (pointed at zampp1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 04:00:10 +00:00
92b460f503 fix(recorder): finalise live asset on stop + add live SDI monitor
Stuck-live fix: capture sidecar now finalises the pre-created live asset by id (new POST /assets/:id/finalize) instead of POSTing a new asset (409 collision); node-agent gives the sidecar a 180s stop grace so the S3 upload + callback complete; node-agent logs sidecar start/stop for diagnostics.

Live SDI monitor: HLS preview is now a 2nd output of the hires ffmpeg (single DeckLink read, split to ProRes/S3 + H.264/HLS); node-agent serves /live over HTTP; mam-api proxies GET /recorders/:id/live/* to the recorder node; web-ui HlsPreview loads from the proxied URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 03:20:20 +00:00
500599a955 fix: pass CAPTURE_TOKEN env var to mam-api container 2026-05-28 22:13:36 -04:00
634f1842bd fix: add Bearer auth to capture sidecar callback and pass CAPTURE_TOKEN
- capture/src/index.js: read MAM_API_TOKEN from env; include
  Authorization: Bearer header in shutdown callback fetches to mam-api
  (POST /assets and POST /assets/:id/mark-empty). Without this, mam-api
  AUTH_ENABLED=true rejects the callback with 401, leaving assets stuck in live
- recorders.js: pass MAM_API_TOKEN=${CAPTURE_TOKEN} in sidecar env so the
  capture container receives the token at boot
- api_tokens: inserted capture-sidecar token (unbound, prefix b3d3d3c4)
2026-05-29 01:57:39 +00:00
453103aee6 fix: use external MAM_API_URL for remote capture sidecars; add cluster metrics endpoint and dashboard resource graphs
- recorders.js: when isRemote=true, replace MAM_API_URL in sidecar env with
  http://<NODE_IP>:<PORT_MAM_API> so capture containers on worker host network
  can reach mam-api (fixes assets stuck in live status after recorder stop)
- cluster.js: add GET /api/v1/cluster/metrics endpoint returning per-node
  cpu/ram/gpu utilization; update heartbeat handler to persist metrics JSONB
- web-ui: add Resources panel to dashboard with live CPU/RAM/GPU bars per node,
  polling /api/v1/cluster/metrics every 5s
2026-05-29 01:04:24 +00:00
6f64b55824 feat(ui): add 'Cancel all failed' button to Jobs screen
Pair with the existing 'Retry all failed'. Drops every failed job from
the queue at once. Single confirm prompt. Optimistic local update so the
list clears instantly instead of waiting for the 5s poll tick.

.jobs-cancel-all CSS tinted danger-red without being a loud .btn danger,
matching the per-row Cancel pattern.
2026-05-29 00:02:55 +00:00
303f12e0f9 ui: add Billing admin nav, drop Editor nav, replace Editor tile with Premiere panel download modal
- shell.jsx: add Billing item under Admin (routes to the parody pricing
  page); drop Editor from the Operations section
- app.jsx: route 'billing' to TokensParody; remove 'editor' and
  'tokens-parody' routes
- screens-admin.jsx: rename parody h1 from 'Tokens' to 'Billing'; drop
  the cross-link from the real Tokens page (no longer needed)
- screens-home.jsx: replace the Editor launcher tile with a 'Premiere
  panel' tile that opens a new PremiereDownloadModal listing every
  registered release (ZXP + Windows installer) with version + LATEST
  badge + release date + notes
- styles-screens.css: .premiere-release-* row styles for the modal

Editor screen + nav button retired; users get the Premiere panel as the
recommended editor instead, with all download options in one place from
Home.
2026-05-29 00:01:19 +00:00
342b56af35 ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.

## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)

## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
  styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)

## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
  width on progress bar). Live DOM was 487 inline-styled elements due
  to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
  job-row-actions, job-row-status-* utility classes in styles-screens.css

## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
  Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
  not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor

## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
  beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)

## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing

## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
  live recorders, 'Last 24 hours' tiles for newly created assets, plus
  an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen

## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title

## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
  badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 23:50:07 +00:00
f54c49d2dc fix(web-ui): fix duplicate DeckLink groups in new-recorder modal; refactor device pickers
The /cluster/devices/blackmagic endpoint returns one entry per port (flat
array). The old SDI picker iterated over each entry and synthesised
port_count buttons per entry — 4 entries × 4 synthesised ports = 16 buttons
rendered as 4 identical duplicate groups.

Fix: extract DevicePortPicker component that groups the flat per-port
response by node_id (Map keyed on node_id, one group per physical node,
ports sorted by index). One button rendered per actual API entry.

Also extract ManualDevicePicker for the fallback empty-state dropdowns.
Both components shared between SDI and Deltacast pickers.

Visual improvements:
- Port label shows device node (io0, io1…) from device path instead of
  redundant index number
- Node header only shows model+hostname, not repeated per port
- TEST CARD badge styled inline for Deltacast test-card ports
2026-05-28 23:18:55 +00:00
888ca65045 feat(capture): Deltacast SDI framework — test-card capture, cluster detection, UI
## capture service
- capture-manager.js: add 'deltacast' source_type to _buildInputArgs.
  Uses 'deltacast://<index>' with ffmpeg deltacast demuxer when
  /dev/deltacast<N> exists; falls back to lavfi testsrc2 + sine test card
  (matching deltacast-sdi-recorder standalone app) when hardware absent.
- routes/capture.js: add GET /devices/deltacast endpoint (enumerates
  /dev/deltacast* + DELTACAST_PORT_COUNT env fallback). Extend /probe to
  handle source_type=deltacast.

## node-agent
- detectHardware(): add 'deltacast' array to capabilities payload.
  Enumerates /dev/deltacast* nodes; falls back to DELTACAST_PORT_COUNT env.
  Adds DELTACAST_MODEL env support. Logs dc= count in heartbeat line.
- sidecar /start: bind /dev/deltacast* device nodes into capture containers
  when sourceType='deltacast'.

## mam-api
- cluster.js: add GET /cluster/devices/deltacast and
  GET /cluster/devices/deltacast/signal endpoints — same shape as
  blackmagic equivalents for UI parity.
- recorders.js /start: pass DELTACAST_PORT_COUNT env to capture container;
  bind /dev/deltacast* device nodes on local spawn.
- migration 024: ALTER TYPE source_type ADD VALUE 'deltacast' (idempotent).
- schema.sql: add 'deltacast' to source_type ENUM for fresh installs.

## web-ui
- modal-new-recorder.jsx: add 'Deltacast' source type card; fetch
  /cluster/devices/deltacast on selection; port picker with TEST CARD
  badge when hardware absent; falls through to manual index entry if
  no devices detected.
2026-05-28 23:12:40 +00:00
b6f5b9b407 fix(capture): disable concurrent SDI proxy ffmpeg — DeckLink Duo 2 rejects second reader
DeckLink Duo 2 does not support two simultaneous ffmpeg processes on the
same port. The second (proxy) process immediately gets 'Cannot Autodetect
input stream or No signal', producing an empty upload that could crash the
container before the hires upload completes.

Fix: remove the parallel proxy spawn for SDI entirely. proxyKey is now
always null for SDI recordings (same as SRT/RTMP). needsProxy=true is
already set when proxyKey is null, so the BullMQ worker generates the
proxy from the hires master after stop — same pattern that works for
network sources.

Also revert bad regex change: ffmpeg -sources decklink output on this
hardware uses hex-address format ('81:76669a80:00000000 [DeckLink Duo (1)]')
not bare indented names — original regex was correct.
2026-05-28 22:36:06 +00:00
354731a363 fix(capture): fix DeckLink device name enumeration for SDI port 2+; add per-take project selector on Recorders page
- capture-manager.js, routes/capture.js: fix ffmpeg -sources decklink
  parse regex from v4l2 hex-address format (never matched DeckLink output)
  to correct indented-line format. Port 2+ (index 1+) was falling through
  to a wrong model-name fallback, causing ffmpeg to open the wrong input
  and produce black frames. Now logs the detected device list and the
  selected name at start.

- recorders.js (/start): accept per-take projectId override in request
  body. If provided, clips go to that project instead of the recorder's
  default project_id. Used for both the live-asset INSERT and the
  PROJECT_ID env var passed to the capture container.

- screens-ingest.jsx (RecorderRow): add project dropdown shown when
  recorder is stopped. Defaults to the recorder's configured project;
  operator can change it before hitting Record without editing the
  recorder config.
2026-05-28 22:26:08 +00:00
Claude
1fcb927d26 feat(web-ui): library Download button + dismissable size warning (#145)
Adds an inline hi-res download trigger to the asset library.

UI:
- Small 22×22 download icon button in the top-right corner of each
  asset thumbnail. Hidden by default, fades in on card hover or focus
  so the resting-state grid stays clean.
- Only renders for assets that have an `original_s3_key` — proxies
  and unfinished captures never offer it.
- Mirrored as a "Download original…" entry in the right-click
  AssetContextMenu (between Rename and the bin actions).

Flow:
- First click (or any click while the warning is enabled) opens
  DownloadWarningModal: terse copy explaining the file is the full
  original ingest, can be multi-GB, and that speed depends on the
  user's network connection. Footer: Cancel · Download. Body: a
  "Don't show this again on this device" checkbox.
- Ticking the checkbox persists `df.lib.download.warnDismissed=1`
  in localStorage. Subsequent clicks skip the modal and start the
  download straight away.

Download itself reuses /api/v1/assets/:id/hires (presigned S3 URL)
— no proxy round-trip through mam-api, no in-browser progress UI
beyond what the browser already shows.

Spec: #145
Settings → Account "re-enable the warning" toggle is not in this
patch and will land separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:14:24 -04:00
Claude
6bc6478270 feat(worker): conform — queue proxy build for the conformed output
ProRes / DNxHR conformed outputs are unplayable in the browser
(HTML5 video: MEDIA_ERR_SRC_NOT_SUPPORTED). The library was
referencing the ProRes original as the only source.

After the asset row is inserted, queue an H.264 proxy build the same
way services/mam-api/src/routes/assets.js does on ingest:
  proxyQueue.add('generate', {
    assetId,
    inputKey:  outputKey,         // the conformed mov / mp4
    outputKey: `proxies/${id}.mp4`,
  });

The proxy worker writes the H.264 mp4, updates assets.proxy_s3_key,
and from then on /assets/:id/stream prefers the proxy over the
original. The library player can decode it natively.

Failure to enqueue is logged but doesn't fail the conform job — the
asset still exists and can have a proxy re-queued later.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:49:01 -04:00
Claude
446a563647 fix(worker): conform — write ProRes/DNxHR to MOV, not MP4
The final concat-demux + encode step erred with:
  [mp4] Could not find tag for codec prores in stream #0,
        codec not currently supported in container
  [out#0/mp4] Could not write header (incorrect codec parameters ?)

ProRes and DNxHR live in QuickTime (.mov), not MP4. The output path,
S3 key, and asset-row filename were all hardcoded to .mp4.

Pick the container from the codec:
  prores / prores_hq / prores_4444 / dnxhr_hq → mov
  h264 / h265 / anything else                 → mp4

outputExt is computed once at the top of the worker (before tmpfile
creation) and reused for the temp output, the S3 key
(jobs/<id>/conformed.<ext>), and the assets row's filename column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:40:53 -04:00
Claude
71d8944a01 fix(worker): conform — use aformat for channel layout (ffmpeg 8 dropped aresample ocl)
ffmpeg 8.x removed the `ocl` shortcut option from aresample (it was a
deprecated alias for out_chlayout). The per-segment trim+normalise call
errored immediately:
  [fc#-1] Error applying option 'ocl' to filter 'aresample': Option not found

Split the chain: aresample handles the sample rate, aformat asserts +
auto-converts to stereo + fltp.

  aresample=48000,aformat=channel_layouts=stereo:sample_fmts=fltp

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:37:35 -04:00
Claude
686b90294b fix(worker): conform — 2-pass strategy (normalise on trim, demux on concat)
ffmpeg 8.x's concat filter kept dying with the opaque
  [fc#0] Error sending frames to consumers: Invalid argument
even after we locked fps + sample rate + pixel format + SAR in the
filter graph. Mixed sources (AV1+H.264, 23.98+60 fps, 44100+48000 Hz,
tv-range+unspecified-range pixel format) just don't survive the
concat filter cleanly in this build.

Switch to the more reliable 2-pass pattern:

1. At the trim step, re-encode each segment to a uniform intermediate
   spec: libx264 ultrafast, 1920x1080 (letterboxed), yuv420p,
   seqFps target rate, 48kHz stereo AAC. Per-segment ffmpeg.

2. At the concat step, use the concat *demuxer*. Because every input
   now matches exactly, the demuxer is well-behaved. Transcode the
   concatenated stream to the final target codec (ProRes 422 HQ etc).

Costs an extra intermediate encode (libx264 ultrafast ≈ realtime on
this hardware) but eliminates the filter-graph fragility on mixed-
source timelines, which is the workload that actually matters.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:52 -04:00
Claude
fcf4c8bbe7 fix(worker): conform — lock fps + sample rate in concat filter graph
After the demuxer → filter switch, concat still failed with
  [fc#0] Error sending frames to consumers: Invalid argument
on Job 8. The filter graph normalised pixels (scale+pad+yuv420p) but
left the time-domain axes mixed:

  segment-1: 23.98 fps video, 44100 Hz audio
  segment-2: 60    fps video, 48000 Hz audio
  segment-3: …

ffmpeg 8's concat filter requires identical frame rate + audio sample
rate + channel layout across inputs. Force them on each leg:

  video: fps=<seqFps>, setpts=PTS-STARTPTS
  audio: aresample=48000,
         aformat=channel_layouts=stereo:sample_fmts=fltp,
         asetpts=PTS-STARTPTS

setpts/asetpts re-zero each input's clock so concat's per-input PTS
window resets cleanly between segments.

Target fps comes from the sequence's frame_rate (rounded) — same axis
the sequence editor stores. Sample rate is pinned to 48000 (broadcast
standard) so the AAC encode is consistent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:21:23 -04:00
Claude
94b6710e2d fix(worker): conform — concat-filter for mixed source formats
ffmpeg concat demuxer dies with "Error sending frames to consumers:
Invalid argument" when input segments don't share codec / pixel format
/ framerate / resolution. Mixed-source timelines hit this every time —
e.g. an AV1 clip + an H.264 clip going through the same concat.

Switch to the concat *filter*. It re-encodes through a filter graph
so disparate inputs are normalised inline. Each input is scaled to
1920x1080 with letterbox, format=yuv420p, audio resampled. concat=n=N
joins them into [outv]/[outa].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:04:55 -04:00
Claude
6412b5c252 fix(worker): conform — preserve audio + map ProRes/DNxHR codecs
Three cooperating bugs left the rendered output silent and in the
wrong codec:

1. executor.js trimSegment used `-frames:v` with no audio mapping.
   ffmpeg dropped the audio track on each segment before they reached
   the concat step. Add `-c:a copy -shortest` so each segment carries
   its original audio.

2. conform.js audioFlag was `audio === 'include' ? aac : -an`. The
   panel's v2.2.1 defaults send `audio: 'broadcast'`, which didn't
   match 'include' → `-an` explicitly stripped audio at the encode
   step. Switch to the opposite default: only an explicit 'none' or
   'off' disables audio; everything else gets AAC 320k @ 48kHz.

3. conform.js video codec map only matched `codec === 'prores'`. The
   panel sends `'prores_hq'` (and the conform slide panel can send
   `'prores_4444'` / `'dnxhr_hq'`). All of those fell through to
   libx264 and silently rendered H.264 instead of the requested codec.
   Add a real codec map with the right prores_ks profiles (3=HQ,
   4=4444) and DNxHR. Skip -crf for ProRes since the profile encodes
   quality.

The asset-row metadata's `codec` column is normalised the same way so
the new asset record matches what was actually written.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:32:49 -04:00
Claude
56d7479a35 fix(mam-api): pass project_id into conform job so render can register the asset
The conform worker's final step INSERTs the rendered output into the
assets table:

  INSERT INTO assets (project_id, filename, display_name, …)
  VALUES ($1, …)
  -- project_id NOT NULL

It reads projectId from job.data, but the /sequences/:id/conform
endpoint never set it. Render finished cleanly, ffmpeg ran, output
uploaded to S3, then the final asset row INSERT failed:
  null value in column "project_id" of relation "assets"

Pass seq.project_id from the loaded sequence row. The rendered output
lands as an asset under the same project as its source sequence —
the natural target.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:24:04 -04:00
Claude
aeecb6e32a fix(worker): conform — resolve clips from sequence_clips instead of filename
Panel had been sending xmeml with clipitem/name = the local Premiere
file path's basename (e.g. "dragonflight-Interstellar - Docking Scene
1080p IMAX HD.mp4"). The worker's old filename lookup ran
  SELECT id, original_s3_key FROM assets WHERE filename = $1
which never matched, because the assets row's filename is the
original MAM ingest name without the "dragonflight-" prefix.

Fix: when job.data has sequenceId (always set by the conform endpoint
at routes/sequences.js:317), pull edits directly from sequence_clips,
which the panel already wrote with authoritative asset_id mappings on
push. We JOIN to assets for original_s3_key + filename and order by
(timeline_in_frames, track) so segment indices stay deterministic.

The XML is still parsed for sequence-level metadata (name, fps) when
provided, but its clipitems are no longer authoritative.

The legacy filename path (EDL input or fcpXml without sequenceId)
stays unchanged for backward compatibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:08:49 -04:00
Claude
0abef056e7 fix(uxp+mam-api): Export Timeline render — xmeml schema + BullMQ job poll
Two cooperating bugs left Export Timeline stuck at "Rendering Hi-Res"
forever:

A. worker emitted "Invalid FCP XML: no sequence element" because
   Timeline.generateFcpXml produced fcpxml (FCP X schema:
   <fcpxml><resources>/<library>/...) while the worker's parseFcpXml
   expects xmeml (FCP 7 schema: <xmeml><sequence>...). Two completely
   different formats.

   Rewrite generateFcpXml to emit xmeml v5 with the structure the
   parser walks:
     xmeml/sequence/{name,duration,rate{timebase,ntsc},
                     media/video/{format/samplecharacteristics,
                                  track[@currentExplodedTrackIndex]
                                  /clipitem/{name,duration,rate,in,out,
                                             start,end,file/{name,pathurl}}}}
   Clipitem in/out are SOURCE frames (the underlying media in/out);
   start/end are TIMELINE frames (the cut position). The worker uses
   the rate timebase to parse them.

B. /api/v1/jobs/:id rejected the panel's polls with
   "Invalid id — must be a UUID". The handlers below correctly parse
   BullMQ-prefixed ids ("conform:42"), but router.param('id',
   validateUuid('id')) ran first and 400'd everything that wasn't a
   UUID. The panel's pollConform swallows the resulting fetch error
   silently and polls forever.

   Drop the validator. Comment in the file explains why.

Bumps panel to v2.2.2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:58:13 -04:00
Claude
540d333758 feat(uxp): v2.2.1 — Export Timeline is now one-click push + render → asset
Contract: clicking Export Timeline does the whole pipeline with no
prompts. Behavior matches what the user actually expected from the
button label:

  1. readActiveSequence — pulls the Premiere timeline + clip map
  2. resolveExportProject — picks the target MAM project. First run
     uses the first project on the server and caches its id in
     localStorage (df.uxp.exportProjectId). Subsequent runs reuse
     the cache. If the cached project was deleted server-side we
     transparently re-pick.
  3. Timeline.startConform with sensible defaults:
       codec=prores_hq, quality=high, resolution=source, audio=broadcast
     This both pushes the sequence + clip rows AND queues a real
     conform job (the prior Push-to-MAM button never queued a job,
     which is why "no jobs spin up" happened earlier).
  4. pollConform every 2s, mapping job progress 20→95% on the
     panel progress bar.
  5. On completion, toast + Library.refresh() so the rendered hi-res
     asset shows up in the grid without needing to click around.

The Conform slide panel stays wired for Advanced → Export & Conform
so power users can still override the codec/preset for one-off jobs.
The Push-only slide panel that this replaces is now orphaned chrome
and will be removed in a later cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:28:51 -04:00
Claude
e4e69973e5 ui(uxp): v2.2.0 — density pass: drop details panel, tighter buttons, collapsible Advanced
User feedback after v2.1.9: panel still chrome-heavy. The Asset Info
panel duplicates what the card already shows; 8 buttons across 3
full-width rows still claim too much vertical real estate.

Three surgical changes:

1. Drop the Asset Info details panel entirely. Card meta (name +
   duration + codec) already carries everything we showed in the
   key:value table. Library._showDetails / hideDetails become no-ops
   so the existing call sites in main.js + library.js don't need
   conditional branches.

2. Shrink .action-row .btn to 20px tall, 10.5px font, 6px horiz
   padding, 3px radius. Two rows of compact buttons fit where one
   bulky row used to.

3. Collapse Advanced section behind a toggle (▸ / ▾). Default
   collapsed so the main 6 buttons stay the primary action surface;
   click the row to expand and reveal Export & Conform / Fetch &
   Relink All.

Per DESIGN.md "density over whitespace."

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:03:56 -04:00
Claude
c3b087020d ui(uxp): v2.1.9 — visible version chip + diagnose multi-version install
UPIA stacks every install in its own
  C:\Program Files\...\UXP\Plugins\External\net.wilddragon.dragonflight.uxp_<version>\
folder without removing prior versions. After 10 deploys today there are
11 of them coexisting, and Premiere's loader can pick the wrong one,
which is why v2.1.8 didn't appear to land.

This change makes the running version visible at a glance:

- main.js reads manifest.json at runtime via require('uxp').storage
  .localFileSystem.getPluginFolder() so the displayed version is
  whatever Premiere actually loaded — never a hand-edited constant
  that could drift.
- index.html adds #panel-version inside the status strip (between
  host and ⋯) and #brand-version below the brand tag on connect.
- styles.css: small mono chip in --text-4, low key but readable.

If the chip ever shows the wrong version we know the loader picked
a stale dir; if it shows nothing the manifest read itself failed.

The install script needs to remove old _<version> dirs going forward;
the next commit will add that cleanup step to the deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:00:50 -04:00
Claude
c2a6c1557b ui(uxp): v2.1.8 — density redesign on top of v2.1.7
Three changes, surgical so timeline.js / conform / relink / growing
all keep working:

A. Header → 24px status strip + ⋯ menu
   `connected-bar` rule kept as an alias to `.status-strip` so any code
   path that still emits the old class falls through cleanly. Markup
   replaced with .signal-dot + #connected-host + .btn-ghost ⋯ that
   toggles a .menu containing the Disconnect button. Menu auto-closes
   on outside click. Reclaims ~12px of permanent vertical chrome and
   removes the always-visible Disconnect.

B. Compact action footer
   `.action-row .btn` now: 22px tall, 11px font, 0.01em letterspacing.
   `.advanced-section .action-row .btn` goes a step smaller (20px /
   10.5px). Global `.btn` untouched so #connect-btn stays at full
   weight on the connect pane.

D. Token alignment with services/web-ui DESIGN.md
   --bg-0 #0B0D11 (was #0e0f12), --accent #5B7CFA (was #4f7cff),
   plus the full --text-1..4 / --success / --warning / --danger / --live
   palette. Legacy --ok / --warn aliased to --success / --warning so
   existing rules keep resolving.

C (per-card meta) was already in v2.1.7 — no change needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:51:47 -04:00
Dragonflight Deploy
b04882a310 release(uxp): v2.1.7 — fix resource busy + export timeline robustness 2026-05-28 11:12:49 -04:00
60e5093c6b fix(uxp): null-safe Time object access in readActiveSequence
getStartTime/getEndTime/getInPoint/getOutPoint can return null for
non-clip track items (gaps, transitions) that slip past the
getProjectItem check. Accessing .seconds on null threw a TypeError
that the outer catch swallowed — silently dropping every clip and
leaving clips[] empty, so the export panel never opened.

Also skip clips where all four time values resolve to 0 (filler items).
2026-05-28 11:12:32 -04:00
382f432693 fix(uxp): resolve "Resource busy" on re-import of same asset
- _writeBuffer: catch EBUSY (Windows file-lock) and treat as success —
  the file is already there from the previous import and Premiere has it
  locked; no need to re-write it.
- proxy / hires: stat the destination first; if the file already exists
  skip the download entirely and go straight to importIntoProject.
- importIntoProject: importFiles returning false means the file is
  already in the Premiere project — not an error, treat as success.
2026-05-28 11:11:32 -04:00
Dragonflight Deploy
e533566ae2 release(uxp): v2.1.6 — fix thumbnail auth (bearer fetch + blob URL) 2026-05-28 09:53:34 -04:00
c3e4306d9f fix(uxp): fetch thumbnails via API.request() to carry Bearer token
img.src direct assignment never sends Authorization headers, so all
thumbnail requests returned 401 once the global auth gate was enabled.
Now fetches via API.request(), converts response to a blob URL, and
assigns that to img.src. Falls back to the placeholder div on error.
2026-05-28 09:41:26 -04:00
eeb0d9f65f UXP v2.1.5b: main.js relink handler await fix + artifact 2026-05-28 09:15:24 -04:00
7f0ca5922f UXP v2.1.5: main.js — fix relink handler: await getActiveProject, use requestExternal + arrayBuffer 2026-05-28 09:15:06 -04:00
469521d524 UXP v2.1.5 release artifact 2026-05-28 09:07:15 -04:00
07840441b9 UXP v2.1.5: bump manifest version 2026-05-28 09:07:00 -04:00
5774f61ac7 UXP v2.1.5: timeline.js — await all premierepro calls; runtime is async 2026-05-28 09:06:43 -04:00
1fb790a569 UXP v2.1.5: import-flow — await all premierepro calls (runtime is async despite docs saying sync) 2026-05-28 09:05:49 -04:00
11cb93aa51 UXP v2.1.4 release artifact 2026-05-28 08:32:55 -04:00
dbc67636b2 UXP v2.1.4: bump manifest version 2026-05-28 08:32:35 -04:00
460b590d46 UXP v2.1.4: timeline.js — replace API.requestFollow with API.requestExternal for batch relink S3 downloads 2026-05-28 08:32:18 -04:00
f3a640a7c5 UXP v2.1.4: api.js — remove redirect:manual (not supported in UXP fetch); UXP auto-follows redirects 2026-05-28 08:31:09 -04:00
baa289f6c3 UXP v2.1.4: import-flow — drop redirect:manual (not supported in UXP fetch, causes null body); use arrayBuffer() fallback if body.getReader unavailable 2026-05-28 08:30:30 -04:00
e5f218655e UXP v2.1.3 release artifact 2026-05-28 07:50:52 -04:00
8119b57b45 UXP v2.1.3: bump manifest version 2026-05-28 07:50:29 -04:00
9765fd91f7 UXP v2.1.3: main.js — fix inline relink handler (sync getActiveProject, no require inline) 2026-05-28 07:50:18 -04:00
a25e4b6071 UXP v2.1.3: timeline.js — correct Premiere DOM API calls per official docs 2026-05-28 07:48:57 -04:00
046d99f57a UXP v2.1.3: import-flow — use window.path.join, replace fs.createWriteStream with fd-based chunked write 2026-05-28 07:47:44 -04:00
fcc737e05b UXP v2.1.2 release artifact 2026-05-28 07:20:14 -04:00
8f93302f45 UXP v2.1.2: bump manifest version 2026-05-28 07:20:09 -04:00
17ca9bfc75 UXP v2.1.2: import-flow — replace path.join (not in UXP) with manual join helper 2026-05-28 07:19:46 -04:00
f8fa0fa010 UXP v2.1.1 release artifact 2026-05-28 02:25:30 -04:00
907058de83 UXP v2.1.1: bump manifest version 2026-05-28 02:25:21 -04:00
bfe0316067 UXP v2.1.1: add conform-proj-select to Conform panel 2026-05-28 02:24:58 -04:00
5d94838830 UXP v2.1.1: main.js — fix recordImport from proxy/hires return values, add conform project select, fix conform panel 2026-05-28 02:24:07 -04:00
76fff5efc2 UXP v2.1.1: import-flow.js — expose _tempPath/_streamToFile, return {localPath,safeName} from proxy/hires 2026-05-28 02:22:58 -04:00
5432c2dfa1 UXP v2.1.0 release artifact 2026-05-28 02:21:36 -04:00
b3b2655272 UXP v2.1.0: bump version in manifest 2026-05-28 02:20:43 -04:00
16366267c4 UXP v2.1.0: main.js — full rewrite, wire all panels, tabs, export, conform, relink, mount live 2026-05-28 01:01:29 -04:00
066718c968 UXP v2.1.0: timeline.js — new module: sequence read, FCP XML, export, conform, batch relink via UXP premierepro API 2026-05-28 01:00:19 -04:00
60d0b09c63 UXP v2.1.0: ui.js — add formatDuration, sanitizeFilename, slide panel helpers, escapeXml 2026-05-28 00:59:21 -04:00
2608d7a465 UXP v2.1.0: library.js — project filter, status badges, details panel, growing tab, growing poll 2026-05-28 00:58:57 -04:00
cd18988d6d UXP v2.1.0: api.js — add projects, live-path, sequences, conform, batch-trim endpoints 2026-05-28 00:57:59 -04:00
be57eb0a50 UXP v2.1.0: CSS — full rewrite, all new panels, tabs, details, badges 2026-05-28 00:57:23 -04:00
25356ca439 UXP v2.1.0: full feature parity with V1 CEP — tabs, details, export, conform, relink, mount live 2026-05-28 00:56:15 -04:00
Claude
4bea3c94f8 fix(premiere-plugin-uxp): v2.0.2 — resolve temp folder defensively (no os.tmpdir)
UXP's `os` is a stripped subset of Node's — `os.tmpdir()` isn't exposed in
the build PPro 26.0.x ships, so both Import Proxy and Import Hi-Res failed
immediately with "os.tmpdir is not a function".

Replace with a defensive resolver tried in order:
  1. os.tmpdir if present (newer UXP builds)
  2. require('uxp').storage.localFileSystem.getTemporaryFolder() → .nativePath
     (the documented portable approach)
  3. process.env.TEMP / TMP / LOCALAPPDATA\\Temp (Windows always sets these)
  4. os.homedir() + AppData/Local/Temp

tempPath() is now async; both Import.proxy and Import.hires await it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:43:44 -04:00
Claude
f1a3d6a24a fix(premiere-plugin-uxp): v2.0.1 — replace unsupported CSS + surface load count
The v2.0.0 grid stayed empty in Premiere 26 because UXP's CSS engine
doesn't support `grid-template-columns: repeat(auto-fill, minmax(...))`
or `aspect-ratio`. Cards rendered with 0 height and the flex column
collapsed, so the actions row stuck to the top of the pane.

Switch to flex-wrap with fixed-width (140px) cards and explicit 80px
thumb heights — both work in UXP's stripped CSS.

Also fix the /auth/me response shape — it returns the user fields
directly, not wrapped in `{ user: ... }`. Header now shows
"display_name @ host" instead of falling back to bare host.

Add a toast on each library load reporting "Loaded N assets (total M)"
so we can tell empty-grid (zero assets) from CSS-broken-grid (cards
exist but invisible) at a glance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:35:04 -04:00
Claude
91e4691230 feat(premiere-plugin-uxp): v2.0.0 — UXP port replacing CEP for import
CEP `csInterface.evalScript` callback is broken in Premiere Pro 26.0.x —
nothing called from the panel ever returns, so importFiles deadlocks. Adobe's
path forward is UXP. This is the minimum viable port that restores the
Import Proxy / Import Hi-Res workflow.

Scope (v2.0.0):
- Connect to a Dragonflight server (URL + Bearer token; persisted)
- Asset library (search, refresh, grid with thumbnails)
- Import Proxy via streamed download → Project.importFiles
- Import Hi-Res via presigned S3 URL → Project.importFiles

Layout:
  manifest.json     UXP v5, host=premierepro, minVersion=26.0.0
  index.html        Panel shell
  styles.css        Mirrors web UI dark tokens
  src/ui.js         DOM helpers, toast, progress, formatting
  src/api.js        HTTP client (Bearer; manual redirect-follow drops auth
                    when hopping to a different host per UXP security policy)
  src/library.js    Asset grid render + selection
  src/import-flow.js  Streaming download (fs.createWriteStream) +
                      premierepro.Project.importFiles into rootBin
  src/main.js       Bootstrap, event wiring
  build/pack.mjs    Packs into .ccx; installs via UnifiedPluginInstallerAgent

Coexists with services/premiere-plugin/ (CEP) — keeps the CEP panel for any
features that still work there while running v2.0.0 for import. Future v2.x
will add live preview, conform, timeline export, settings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:19:28 -04:00
Claude
8b48f03f6b diag(premiere-plugin): v1.2.5 — no-op IIFE writes to Documents/ + reports lf.open result 2026-05-28 03:59:40 +00:00
Claude
9085835074 diag(premiere-plugin): v1.2.4 — fix pre-importFiles log syntax (safePath as string literal not bareword) 2026-05-28 03:50:42 +00:00
Claude
f5959620c8 diag(premiere-plugin): v1.2.3 — file-log around importFiles call
Writes timestamped pre/post lines to C:/Temp/df-import-log.txt around the importFiles call so we can see whether importFiles hangs (only pre line present) or returns and evalScript callback gets lost (both lines present). Diagnostic only.
2026-05-28 03:46:00 +00:00
Claude
e3afe38697 fix(premiere-plugin): suppress importFiles UI prompts + 60s timeout guard
app.project.importFiles() can deadlock if a hidden Premiere modal appears (off-screen, behind window, etc) — the evalScript callback never fires and the panel spinner hangs forever.

Two changes:

1) Pass suppressUI=true to all five importFiles call sites (main.js inline IIFE + 4 in premiere.jsx). Premiere proceeds even if it would have prompted (audio sample rate, project link, scale-to-frame, etc).

2) Wrap importFileToPremiereProject in a 60s timeout race so even if importFiles does block, the panel surfaces a real error instead of leaving the spinner stuck.

Bumps to v1.2.2.
2026-05-28 03:19:44 +00:00
Claude
e7eff0ee8c release(premiere-plugin): v1.2.1 with downloadFile Bearer + 15s timeout fix 2026-05-27 23:07:27 -04:00
Claude
e8ceb991a3 fix(premiere-plugin): inject Bearer in downloadFile + add 15s timeout
downloadFile() uses native https.get which bypasses the window.fetch interceptor that injects Authorization. Same-server URLs (proxy /video) hit requireAuth and 401. Inject the Bearer header manually when url starts with state.serverUrl.

Also add a 15s setTimeout so an unreachable presigned URL (or CEP-Node TLS hiccup on broadcastmgmt.cloud) fails fast with an error instead of hanging the spinner forever.
2026-05-28 03:00:02 +00:00
Zac Gaetano
ac7730195d fix(web-ui): forward X-Forwarded-Proto from outer proxy so mam-api emits Set-Cookie
This is the real cause of the login loop. mam-api sets its session cookie
with Secure=true (production config). express-session refuses to emit a
Secure Set-Cookie unless req.secure is true. With `app.set('trust proxy')`
on, req.secure derives from X-Forwarded-Proto.

web-ui's nginx was unconditionally sending `X-Forwarded-Proto: $scheme`.
Inside the web-ui container nginx listens on port 80, so $scheme is always
"http" — regardless of whether the outer NPM proxy terminated TLS. mam-api
saw http, decided the connection was insecure, and silently dropped the
Set-Cookie from the login response. Login succeeded server-side (session
row landed in PG, last_login_at updated) but the browser never received a
cookie, so the very next /auth/me check came back 401 and AuthGate bounced
to the login screen. Infinite loop.

The previous Connection: "upgrade" → $connection_upgrade fix wasn't wrong
(the hardcode is a real latent bug worth fixing) — it just wasn't the
proximate cause.

Fix: a second `map` directive forwards the outer X-Forwarded-Proto through
when present, falling back to $scheme only when no proxy header exists (so
direct localhost curls still work). Both /api/ and /capture/ now send the
correct value upstream, mam-api sees https, req.secure is true, Set-Cookie
flows through, login works.

Verified by curling the existing direct-to-mam-api path: with X-Forwarded-
Proto: https on the request, Set-Cookie comes back; without it, no
Set-Cookie. That's the exact difference between web-ui-proxied and
direct-to-mam-api in our previous diagnostic curls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:11:27 -04:00
Zac Gaetano
c24c6156dc fix(web-ui): stop nginx from eating Set-Cookie on /api/ and /capture/
Login was infinite-looping in production. Server side was healthy (sessions
landing in PG, /me returning 200 to a manually-signed cookie) but the
browser never received `Set-Cookie`. Bisected the proxy chain layer by
layer with direct curls on the box:

  - mam-api direct (port 47432) → Set-Cookie present
  - web-ui nginx (port 47434)   → Set-Cookie STRIPPED
  - NPM (https://dragonflight.live) → Set-Cookie stripped (because web-ui ate it)

Root cause was this in /api/ and /capture/:

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

The literal "upgrade" was being sent on every request, not just real
WebSocket negotiations. Nginx then routes the upstream response through
its tunnel/upgrade code path, which doesn't preserve all response headers
the same way — Set-Cookie got silently dropped. mam-api doesn't speak
WebSockets today so it never sent a 101, and the bad pattern went
unnoticed until session-cookie auth shipped.

Fix is the standard conditional pattern: a `map` directive at the top of
default.conf computes $connection_upgrade as "upgrade" only when the
client actually requested Upgrade, otherwise "close". Both location blocks
now send `Connection $connection_upgrade` instead of the hardcoded literal.
WebSocket support on either location continues to work unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:00:35 -04:00
Zac Gaetano
7e3e6b2a28 fix(auth): force HTTPS on dragonflight.live so login cookies stick
User reported infinite login loop on dragonflight.live. Root cause: openresty
fronts both http:// and https:// without redirecting, and a user landing on
http:// gets the Set-Cookie response silently dropped — cookies are Secure-only
when TRUST_PROXY=true, and the CORS allowlist refuses the http:// origin.
Result: login appears to succeed, next request has no session cookie, AuthGate
bounces back to login.

Two defensive layers (the openresty box is not in our reach):
- web-ui index.html: tiny inline redirect; if location is http://dragonflight.live,
  rewrite to https:// before anything else runs. Bounded to that exact hostname
  so local / LAN access on http://172.18.91.x stays as-is.
- mam-api: emit Strict-Transport-Security on HTTPS responses when AUTH_ENABLED=true.
  After one successful HTTPS visit, browsers auto-upgrade future http:// requests
  on their own — closes the loophole even if someone bypasses the index.html JS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:00:35 -04:00
5571768706 feat(panel): add .connected-bar CSS for compact connected state 2026-05-27 19:31:16 -04:00
350c23f9d1 fix(panel): restore main.js — add disconnectFromServer + connected-bar toggle 2026-05-27 19:28:39 -04:00
Zac Gaetano
8028c4c4dd feat(auth): bound-hostname tokens for node-agent + return role from /me
- requireAuth bearer path now selects api_tokens.bound_hostname and users.role,
  populates req.tokenBoundHostname and req.user.role. /cluster/heartbeat can
  now authenticate via a bound api_token (issued via POST /auth/tokens with
  bound_hostname).
- routes/tokens.js POST accepts bound_hostname; GET returns it so users can
  see which tokens are bound.
- Remove /cluster/heartbeat from SERVICE_PATHS so requireAuth runs on it (the
  bearer auth handles the gate; the heartbeat handler still enforces the
  body.hostname === bound match).
- /auth/me now returns role (final-review I2). Closes the gap where every
  signed-in user appeared as 'viewer' in the UI regardless of actual role.
- loadUser SELECTs role for session auth.
- Backend tests still 37/15/0/22 — no test changes needed; existing token
  CRUD tests stay passing since bound_hostname is optional.
2026-05-27 19:27:59 -04:00
e6da1432e5 fix(panel): restore main.js with disconnect feature (was accidentally emptied) 2026-05-27 19:22:15 -04:00
e22cf625bf feat(panel): add disconnectFromServer(); toggle connection-form / connected-bar
- On connect success: hide form, show compact connected-bar with hostname
- On disconnect: clear assets, reset buttons, restore form
- Wire disconnect-btn click to disconnectFromServer()
2026-05-27 19:21:50 -04:00
552506ec7a feat(panel): collapse connection form when connected; add Disconnect button
On startup the full form shows. On successful connect the form hides and a
compact connected-bar appears with the server hostname and a Disconnect button.
2026-05-27 19:21:38 -04:00
Zac Gaetano
e5c9c770d0 fix(compose): plumb TRUST_PROXY + ALLOWED_ORIGINS through to mam-api container
Task 18 documented the two new env vars in .env.example and README but never
added them to docker-compose.yml's mam-api environment block. Without that,
the vars in .env never reach the container — so AUTH_ENABLED=true was running
with effective TRUST_PROXY=false (req.ip = proxy IP, rate-limit collapses to
per-proxy bucket) and ALLOWED_ORIGINS unset (CORS allows any origin).
2026-05-27 19:16:09 -04:00
a0a6bc9f20 feat(panel): migrate accent palette from hue-32 orange-red to hue-266 blue
Aligns CEP panel with Dragonflight web-ui tailwind.config.js color system.
All bg/accent/text/border tokens updated; signals unchanged.
2026-05-27 19:14:24 -04:00
Zac Gaetano
c8e98ffa0d fix(auth): sync DEV_USER_ID with migration 023 — use all-zeros UUID
Migration 023 was fixed in 9dc572b to use '00000000-0000-4000-8000-000000000000'
because 'v' isn't a valid hex digit, but the DEV_USER_ID constant in
middleware/auth.js still referenced the original '...000000000dev'. Every
route that passes DEV_USER_ID as a query parameter (users list, login lookup,
setup-required count) was throwing 22P02 invalid input syntax for type uuid.
The errors were swallowed by Promise.allSettled in the SPA's data load so the
app appeared to work in dev mode, but enabling AUTH_ENABLED=true would have
broken login entirely.
2026-05-27 19:08:07 -04:00
9dc572b913 fix(migration): replace invalid UUID in 023 dev user seed 2026-05-27 18:45:21 -04:00
Zac Gaetano
14ece1a160 Merge branch 'feat/auth-system' 2026-05-27 15:47:56 -04:00
Zac Gaetano
03d0d098f5 fix(auth): final-review integration fixes — Users page alias + PATCH, CSRF on uploads + heartbeat, drop .bak
Final-review findings:
- Mount usersRouter at /api/v1/users in addition to /api/v1/auth/users so the
  existing SPA Users page works; add PATCH /:id for inline edits (display_name,
  role, password).
- Add X-Requested-With: dragonflight-ui to raw XHR/fetch paths that bypass
  apiFetch (file uploads, SDK uploads, EDL export) — without it, requireUiHeader
  403s before reaching the route.
- Exempt SERVICE_PATHS (/cluster/heartbeat) from requireUiHeader so node-agent
  heartbeats keep working when NODE_TOKEN is unset.
- Remove stale auth.js.bak.
2026-05-27 15:42:42 -04:00
Zac Gaetano
8ede44ae87 docs(auth): flip AUTH_ENABLED default + document setup + recovery 2026-05-27 15:25:29 -04:00
Zac Gaetano
2aec4636cb feat(web-ui): Settings → Account (change password) + API Tokens sections 2026-05-27 15:20:57 -04:00
Zac Gaetano
cfe21e315e feat(web-ui): LoginScreen + SetupScreen (layout B from brainstorm) 2026-05-27 15:17:33 -04:00
Zac Gaetano
7e240d86c8 feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render 2026-05-27 15:08:14 -04:00
Zac Gaetano
96effaaa3c fix(mam-api): TRUST_PROXY boot warning + CSRF integration tests + bounded rate-limit map
Fixes three issues in the authentication system:

C1: Add boot-time warning when AUTH_ENABLED=true but TRUST_PROXY!=true.
    Without TRUST_PROXY=true behind nginx, req.ip becomes the proxy IP for all
    clients, collapsing per-IP rate limiting into a shared pool. Operators must
    explicitly set TRUST_PROXY=true to make per-IP rate limiting effective.

C2: Mount requireUiHeader middleware in test helpers (auth.test.js,
    users.test.js, tokens.test.js). The CSRF header validation was not being
    exercised in the test suite. Tests now send X-Requested-With: dragonflight-ui
    headers that are actually validated by the middleware.

I1: Implement bounded rate-limit Map with MAX_ENTRIES=10000 and LRU eviction.
    Unbounded Maps are vulnerable to spray attacks: attackers can force memory
    exhaustion by requesting with distinct IPs. Now we evict the oldest entry
    (by insertion order) when the map reaches capacity.
2026-05-27 15:03:35 -04:00
Zac Gaetano
d209a192c3 feat(mam-api): login rate limit + X-Requested-With CSRF header check 2026-05-27 14:58:02 -04:00
Zac Gaetano
56b661ef65 feat(mam-api): API token CRUD — show raw once, bearer-authenticate via SHA-256 lookup 2026-05-27 14:52:07 -04:00
Zac Gaetano
b7f5a84d2d feat(mam-api): user CRUD + admin password reset + last-user delete guard 2026-05-27 14:47:03 -04:00
Zac Gaetano
0bbaf80d2a feat(mam-api): GET /auth/me + POST /auth/password 2026-05-27 14:42:53 -04:00
Zac Gaetano
d75a0241eb feat(mam-api): POST /auth/logout 2026-05-27 14:38:05 -04:00
Zac Gaetano
bcfc19e530 fix(mam-api): real dummy bcrypt hash + log last_login_at failures
Code-review feedback:
- Dummy hash for user-enumeration-defense timing was 63 chars (bcrypt strings
  are 60 chars). Worked by accident because bcrypt 5.x is lenient about
  trailing chars; a future tightening would silently regress the timing
  defense. Replaced with a real pre-computed bcrypt hash.
- last_login_at UPDATE now logs errors instead of silently swallowing them,
  matching the pattern in requireAuth for api_tokens.last_used_at.
- Removed dead import of comparePassword from auth.test.js.
2026-05-27 14:35:59 -04:00
Zac Gaetano
f8b6f7d5ef feat(mam-api): POST /auth/login + redirect-loop regression test 2026-05-27 14:28:18 -04:00
Zac Gaetano
c9f9698b58 feat(mam-api): POST /auth/setup — first-run admin creation 2026-05-27 14:24:56 -04:00
Zac Gaetano
49a9543942 feat(mam-api): auth router skeleton + setup-required endpoint 2026-05-27 14:21:32 -04:00
Zac Gaetano
cb7cc9a43e fix(mam-api): narrow cluster carve-out to /cluster/heartbeat only
Code-review feedback: startsWith('/cluster') was a prefix match that exposed
destructive operator endpoints (POST /containers/:id/restart, DELETE /:id,
GET /devices/blackmagic/*) unauthenticated. Only POST /heartbeat is genuine
node-agent traffic; everything else in cluster.js is operator/UI surface
that should go through requireAuth. Long-term: issue node-agent a bound
api_token and drop the carve-out entirely.
2026-05-27 14:18:27 -04:00
Zac Gaetano
9de4fe9ab9 feat(mam-api): mount requireAuth gate at /api/v1 with auth + cluster carve-outs 2026-05-27 14:13:21 -04:00
Zac Gaetano
88c3aa5149 fix(mam-api): SESSION_SECRET boot guard + cleaner CORS rejection
Code-review feedback:
- Hard-fail boot when AUTH_ENABLED=true and SESSION_SECRET is unset, so
  express-session can't silently use an in-memory random secret that
  invalidates sessions on restart and breaks multi-node clusters.
- CORS rejection now returns cb(null, false) instead of cb(new Error)
  so misconfigured origins surface as clean CORS errors in the browser
  instead of HTTP 500s. Log a warn line for operator visibility.
- pruneSessionInterval units comment.
2026-05-27 14:11:09 -04:00
Zac Gaetano
a094df03ea feat(mam-api): wire express-session + tighten CORS allowlist 2026-05-27 14:06:41 -04:00
Zac Gaetano
1a723fe4c2 fix(mam-api): requireAuth — stamp last_seen_at after user confirmation
Code-review feedback: writing last_seen_at = now before loadUser() lets
the stamp persist if the lookup throws (resave:false still writes when
modified), extending the idle window without confirming the user exists.
Also clarify DEV_USER_ID is a specific placeholder, not a generic sentinel.
2026-05-27 14:04:15 -04:00
Zac Gaetano
0248a68f57 feat(mam-api): requireAuth middleware — session + bearer + idle/absolute timeout 2026-05-27 13:59:50 -04:00
Zac Gaetano
3bca290e09 fix(mam-api): test glob — use find so npm test picks up files at any depth
/bin/sh (which npm uses) doesn't expand ** recursively. Task 1's smoke test
under test/ stopped being discovered once Task 3 added tests under test/auth/.
find + sort keeps depth-agnostic discovery portable across shells.
2026-05-27 13:54:12 -04:00
Zac Gaetano
3fc8116dd3 feat(mam-api): auth utilities — password hash/compare + token gen/hash/parse 2026-05-27 13:51:15 -04:00
Zac Gaetano
14931d6362 fix(mam-api): migration 023 — broaden ON CONFLICT + document password_updated_at backfill
Code-review feedback: ON CONFLICT (id) only catches id collisions; a pre-existing
'dev' username would trigger a unique_violation on the username index and roll
back the migration, hard-failing the mam-api boot. Switch to bare ON CONFLICT
DO NOTHING so any unique conflict is no-op-safe.
2026-05-27 13:48:08 -04:00
Zac Gaetano
1d3c0385dd feat(mam-api): migration 023 — auth timestamps + idempotent dev user seed 2026-05-27 13:44:07 -04:00
Zac Gaetano
5011d45391 chore(mam-api): wire node:test runner + test app + DB helper 2026-05-27 13:38:46 -04:00
Zac Gaetano
99fae69960 docs(auth): implementation plan for user auth system 2026-05-27 12:17:11 -04:00
1e51a4ca5d fix(premiere-plugin): align panel CSS with web-ui design system
Component-level alignment pass against services/web-ui/src/css/components/:

- btn-primary text: #070403 (near-black) → #f5f7fb (near-white, matches web-ui)
- btn-danger text: #fcf7f7 → #fbf6f6 (precise oklch(98% 0.005 25) conversion)
- btn-secondary border: border-strong → border; hover: bg-hover + border-strong
- button.secondary legacy: same border/hover fix
- asset-card bg: bg-surface → bg-panel (matches wd-card-asset)
- asset-card hover: remove accent glow + transform + shadow; border → var(--border) only
- asset-card hover brightness: moved to img child only (matches web-ui pattern)
- asset-card selected: remove box-shadow ring; bg → bg-raised
- asset-card border-radius: explicit 6px (was var(--r-md))
- asset-card transition: simplified to border-color with design-system easing
- asset-filename: font 11px → font: 500 12px/1.3 (web-ui uses 13px; 12px for panel density)
- asset-meta: 10px text-secondary → font: 400 11px/1.3 font-mono + text-tertiary + tabular-nums
- asset-status-badge: border-radius 100px → 3px (matches wd-badge)
- chip: pad/gap aligned with wd-badge; font: 600 10px/1; added chip--idle + chip--info variants
- form-label: 10px text-secondary → font: 600 11px/1 + text-tertiary + margin-bottom: 4px
- details-header-label: aligned to font shorthand + 0.08em spacing
- details-label: aligned to font: 600 10px/1
- export-panel-title: font shorthand
- Add @keyframes wd-shimmer + .skeleton utility
- Add @media (prefers-reduced-motion) block
- Update file header comment
2026-05-27 12:00:00 -04:00
Zac Gaetano
c2fd48b0ce docs(auth): clarify dev-user FK seeding and cluster route carve-out 2026-05-27 11:59:19 -04:00
Zac Gaetano
183e10f8e6 docs(auth): spec for user auth system, brainstormed 2026-05-27 2026-05-27 11:57:44 -04:00
ad9e1ef5f1 fix(premiere-plugin): replace oklch() with hex/rgba for CEP Chromium compat
CEP's embedded Chromium (used by Premiere Pro panels) does not support
oklch() color syntax. All color tokens were rendering as invalid/transparent,
causing the panel to appear unstyled. Converted all oklch() values to their
precise hex/rgba equivalents via OKLab→sRGB math. No design changes.
2026-05-27 10:44:39 -04:00
ada8105948 chore(web-ui): bump Premiere panel latest to v1.2.0 in data.jsx (#125) 2026-05-27 10:14:12 -04:00
c84519b606 release(premiere-plugin): publish v1.2.0 ZXP 2026-05-27 10:12:22 -04:00
33239a780e design(premiere-plugin): align panel UI with web-ui design system
- Add motion tokens (ease-out-quart/expo, dur-fast/normal/slide)
- Add z-layer tokens, overlay, thumb-black, accent-hover/bright
- Restructure signal/status tokens; flip to signal-primary/status-alias pattern
- Add signal-info/info-bg for --status-blue backwards compat
- Buttons: md=32px, sm=28px, lg=36px, icon=28sq, font 500/13px
- Inputs/select: 32px tall, bg-deep background, focus-visible outline
- Slide panel: 460px wide, 52px header, 18px padding, ease-out-expo, min-height:0
- Asset card: intrinsic height, thumb-black thumbnail, border-faint, brightness hover
- Status badges: 18px, font-sans, 0.08em tracking
- Chips: 18px, font-sans, no border, signal-bg backgrounds
- Tabs: 36px, no text-transform, pill badge
- Action bar: bg-deep background
2026-05-27 10:09:45 -04:00
7a6113fc90 capture: live port signal presence indicators on Capture screen and nav badge
- Capture screen now polls /cluster/devices/blackmagic/signal every 3s
- Per-port chips show signal state (RECEIVING/CONNECTING/LOST/ERROR/IDLE) with pulsing dot
- BMD SVG card diagram rendered per node card
- Sidebar nav badge on Capture item shows live/total port count (pulsing green dot)
2026-05-27 13:53:32 +00:00
de311321f4 design(premiere-plugin): align panel UI with Dragonflight web-ui design system (v1.2.0)
Rewrites css/styles.css to mirror services/web-ui/src/css/components/*
exactly. Brings the Premiere panel into pixel-level parity with the
main Dragonflight web UI:

- Tokens: add --accent-hover, --accent-bright, --thumb-black, --overlay,
  --shadow, --signal-info, motion tokens (ease-out-quart, ease-out-expo,
  dur-fast/normal/slide), z-layer vars. Keep --status-* aliases pointing
  at --signal-* for main.js backwards-compat. Remove unused --accent-dim
  (hue 52 leftover).

- Buttons: match wd-btn — md=32px (was 28px), sm=28px, lg=36px, icon=28sq.
  focus-visible accent-subtle outline. active opacity 0.85. Replace
  hardcoded oklch(68%) hover with --accent-hover. btn-danger now solid
  signal-bad like wd-btn--danger (was transparent w/ red border).

- Inputs/select/search-input: 32px tall, bg-deep background (was
  bg-surface), accent-subtle focus outline matching wd-input.

- Slide panel: 460px wide (was 420), 52px header (was 40), 18px body
  padding, --overlay scrim, ease-out-expo transform. min-height:0 on body.

- Asset card: removed fixed 155px height (now intrinsic), thumb-black bg
  for thumbnails, brightness 1.04 hover filter mirroring wd-card-asset.

- Status badges: 18px tall like wd-badge, font-sans, 0.08em tracking.

- Chips: 18px tall, font-sans (was font-mono 20px), wd-badge proportions.

- Tabs: 36px, accent underline on active, badge styled as pill.

- Empty state, progress bar, preset cards, clip list, message banners,
  form groups, details panel, action bar, connection bar — all spacing
  + typography refined to web-ui standards.

Manifest bumped to 1.2.0. No JS changes required.: manifest.xml
2026-05-27 09:01:04 -04:00
c48c7e6d7d feat(audio-tab): full audio track inspector with meters, mute/solo, faders
Issue #80 — replaces the stub AudioTab (two static waveforms) with a
broadcast-ops-grade audio panel:

- DB: add audio_metadata JSONB column to assets (migration 022)
- Worker: getMediaInfo now extracts per-stream audio metadata
  (codec, channels, channel_layout, sample_rate, bit_depth, bit_rate,
  language, title, disposition)
- Worker: proxy job persists audio_metadata into the assets row
- API: new GET /assets/:id/audio returns structured track list
- Frontend AudioTab: per-track rows with:
  - Track name/index with language badge
  - SVG waveform per track (color-coded)
  - L/R level meters via Web Audio API AnalyserNode
  - Per-track metadata row (codec, layout, sample rate, bit depth, bitrate)
  - Mute / Solo buttons with proper solo-logic
  - Per-track volume fader
  - Master section with summed L/R meters and master fader
- MetadataTab: show audio track summary when audio_metadata present
- CSS: full audio-tab layout, responsive collapse at 900px
2026-05-27 04:53:52 +00:00
48d54a32cf dashboard: add missing dash-* CSS classes; cluster: add stat-row/stat-card CSS
The Dashboard page was rendering as plaintext because all .dash-* CSS
classes (dash-section, dash-onair-*, dash-jobs-*, dash-cluster-*,
dash-statusbar, etc.) were missing. Added them with the full dark-theme
design-system styling matching the rest of the app.

The Cluster page's .stat-row and .stat-card classes were also missing,
causing node statistics (counts, CPU, GPUs, memory) to render unstyled.
Added grid-based stat row and card styles.
2026-05-27 04:09:15 +00:00
4172b0d70a rip out entire auth/login flow
- remove requireAuth from all route files
- delete auth.js, tokens.js, users.js routes
- delete auth middleware
- remove session middleware and all auth deps from index.js
- delete login.html and auth-guard.js from web-ui
2026-05-27 03:39:58 +00:00
opencode
9726dbb2df Revert "auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap"
This reverts commit 002e5acb82.
2026-05-27 03:28:05 +00:00
opencode
002e5acb82 auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
Scope (locked in via planning Q&A):
  - Identity: local accounts only (PG users table) + existing bearer
    tokens for headless callers.
  - Transport: httpOnly cookie session for browser, Bearer for API.
  - RBAC: admin / editor / viewer roles, plus an orthogonal
    is_client flag for external (agency, talent, customer) accounts.
  - Bootstrap: ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD env
    seed the first admin on a clean install. Set ADMIN_BOOTSTRAP_RESET
    to force-reset the named user (break-glass).
  - Rate limit: in-memory, 10 fails per 15min per (IP, username).
  - Password policy: \u22658 chars, mixed case, digit, symbol; small
    blocklist of common passwords; cannot equal username.
  - Self-service: change own display name + password. Everything
    else (role, is_client, other-user mgmt) is admin only.
  - Audit log: append-only table, indexed by actor + event_type +
    created_at, populated by every auth/admin event.

Files added:
  - services/mam-api/src/db/migrations/022-auth-rework.sql
        users.is_client + last_login_at + failed_attempts; audit_log
        table with FK to users (ON DELETE SET NULL).
  - services/mam-api/src/middleware/audit.js
        Fire-and-forget audit() helper. Caller never awaits, failure
        logs but never throws — auditing cannot break the request
        that triggered it.
  - services/mam-api/src/middleware/passwordPolicy.js
        Shared checkPassword(pw, { username }) used by setup, user
        create/update, and self-service password change.
  - services/mam-api/src/tasks/bootstrapAdmin.js
        Runs after migrations. No-ops unless ADMIN_BOOTSTRAP_USER +
        ADMIN_BOOTSTRAP_PASSWORD are set AND (users table empty OR
        ADMIN_BOOTSTRAP_RESET=true).
  - services/mam-api/src/routes/audit.js
        Admin-only GET /audit (paginated, filter by event_type /
        actor / target / date) and GET /audit/event-types.
  - services/web-ui/public/modal-account-settings.jsx
        Profile + Password tabs. Triggered by sidebar user button.

Files rewritten:
  - services/mam-api/src/routes/auth.js
        - POST /login: regenerate(), no manual save(); audit success/
          fail/lockout; updates last_login_at + failed_attempts.
        - POST /logout: destroys session, audits logout.
        - GET /me: returns is_client + last_login_at. Synthetic admin
          when AUTH_ENABLED=false.
        - GET /setup-status: drives login.html UI state.
        - POST /setup: blocked once any user exists; password policy.
        - POST /password: self-service. Requires current pw, runs
          policy, audits, invalidates other sessions implicitly via
          users.js if changed by admin.
        - PATCH /me: self-service display_name update.
  - services/mam-api/src/routes/users.js
        - is_client field in create/update/list/get.
        - Guardrails: cannot delete or demote last admin, cannot
          delete self, admins cannot be flagged is_client.
        - Password change invalidates all sessions for that user
          (DELETE FROM sessions WHERE sess->>'userId' = id).
        - Audit on every mutation.
        - Password policy enforced.
  - services/mam-api/src/middleware/auth.js
        - requireAuth now exposes req.user.is_client.
        - New requireRole(["admin","editor"], { rejectClients: true })
          helper. Applied to cluster, sdk, capture routes (infra).
        - Synthetic user when AUTH_ENABLED=false has is_client=false.
  - services/mam-api/src/index.js
        - Loads bootstrap admin after migrations.
        - Wires /api/v1/audit.
        - Cleans up an earlier comment block.
  - services/web-ui/public/login.html
        - Password hint added next to setup-mode password field.
  - services/web-ui/public/shell.jsx
        - Sidebar user footer is a button that opens AccountSettings.
        - CLIENT badge next to role when is_client=true.
        - Nav filters: clients lose ingest tree + jobs + editor;
          viewers lose ingest + editor; only admins see the Admin
          section. Power button hidden when synthetic user.
  - services/web-ui/public/screens-admin.jsx
        - Users table: new Client column with inline toggle.
        - InviteUserModal: Client checkbox + password hint, gated
          off when role=admin.
        - Last login column replaces Created in primary view.
        - CSV export includes client + last_login.
  - services/web-ui/public/data.jsx
        - ZAMPP_DATA.ME carries is_client + display_name.
  - services/web-ui/public/index.html
        - Loads dist/modal-account-settings.js.
  - services/web-ui/public/styles-rest.css
        - .user-row grid widened to 6 columns.
  - docker-compose.yml
        - Plumbs SESSION_COOKIE_SECURE + ADMIN_BOOTSTRAP_* env vars.

Deploy:
  cd /opt/wild-dragon
  git pull origin main
  # In .env:
  #   AUTH_ENABLED=true
  #   SESSION_SECRET=<openssl rand -hex 48>
  #   ADMIN_BOOTSTRAP_USER=admin
  #   ADMIN_BOOTSTRAP_PASSWORD=<strong>
  docker compose build mam-api web-ui
  docker compose up -d --force-recreate --no-deps mam-api web-ui
2026-05-27 03:21:16 +00:00
a48e1d9dd7 dashboard: rebuild as control-room status board (on air / up next / attention / work) 2026-05-26 23:10:23 -04:00
opencode
d1f9557dd1 auth: park login flow — circle back
Auth work is parked until after ship. While AUTH_ENABLED=false:
  - login.html now auto-redirects to / on load (no one should ever see
    the login screen while auth is off; it was confusing).
  - sidebar power button is hidden entirely when /auth/me returns a
    synthetic user, so there's no broken-feeling no-op control.
  - Removed connect-pg-simple createTableIfMissing flag in case
    v9.0.1's handling of that option was responsible for the recent
    boot 502 (the schema is created by migration 021 anyway).

The /auth/login + session.regenerate() + cookie fix from c34a721
stays in place — when we re-enable auth it'll work end-to-end. The
sessions table from migration 021 stays. Operator action to restore
auth later: set AUTH_ENABLED=true + SESSION_SECRET=<random> in the
mam-api environment and restart.
2026-05-27 03:04:37 +00:00
34bf1c7b7f fix: remove gradient text from launcher wordmark and token counter (design ban) 2026-05-26 23:02:06 -04:00
opencode
e71c330bdd fix(auth): remove manual session.save() — was suppressing Set-Cookie header
Login was returning 200 + correct user JSON + writing a row to the
sessions table, but emitting zero Set-Cookie headers. Root cause:

  session.regenerate() → set fields → session.save() → res.json()

Calling session.save() manually writes the store but bypasses
express-session's res.end() hook, which is the only path that adds
the Set-Cookie header to the response. The cookie was never sent to
the browser even though the session existed server-side — hence the
redirect loop.

Fix: remove the manual save(). Set the session fields and call
res.json() directly inside regenerate()'s callback; express-session
handles store write + Set-Cookie automatically on res.end().
2026-05-27 02:59:22 +00:00
5de1e3dc3d dashboard: add dense stat cards, cluster bars, job rows, sparkline fixes 2026-05-26 22:58:23 -04:00
e5e0656a6a dashboard: redesign stat cards, compress header, improve density 2026-05-26 22:54:45 -04:00
opencode
65684aa577 fix(auth): ensure sessions table exists + log session.save errors
The redirect loop after successful login was almost certainly the
`sessions` table never being created. `schema.sql` defines it but
only runs on first-init via the postgres entrypoint; instances
bootstrapped via mam-api's own migration loop never got the table.
express-session's `req.session.save()` then failed silently and the
cookie pointed at a sid that wasn't in the store — every subsequent
request looked like a brand-new visitor.

  - New migration 021-ensure-sessions-table.sql (idempotent).
  - connect-pg-simple now configured with `createTableIfMissing: true`
    as belt-and-braces.
  - `POST /auth/login` now explicitly waits for session.save() and
    surfaces both regenerate() and save() errors instead of treating
    them as 'success'. Logs sid + req.secure + req.protocol so we can
    confirm trust-proxy is doing the right thing behind NPM.
2026-05-27 02:54:25 +00:00
opencode
cfcbec0c85 fix(auth): make AUTH_ENABLED=true workable end-to-end
Three concrete issues kept the login flow broken on dragonflight.live:

1. mam-api trusted no proxy headers, so behind nginx/Cloudflare the
   session cookie's `secure` flag and the rate-limiter's IP keying
   both saw the wrong values. Now sets `app.set('trust proxy', 1)`.

2. Session config was tied to NODE_ENV and lacked sameSite/name. Now:
   - SESSION_COOKIE_SECURE env (default: true when AUTH_ENABLED) so a
     site behind HTTPS gets Secure cookies regardless of NODE_ENV.
   - `sameSite: 'lax'` for predictable post-login redirects.
   - Renamed to `df.sid` so it's obvious in DevTools.
   - `rolling: true` extends the 7-day TTL on active use.
   - SESSION_SECRET is now required when AUTH_ENABLED=true; the
     server refuses to start with a dev default in prod.

3. login.html silently showed the sign-in panel even when no users
   exist or auth is off:
   - New GET /auth/setup-status reports {needs_setup, user_count,
     auth_enabled}.
   - login.html calls it on load and auto-flips into setup mode when
     needs_setup is true, or shows an explicit "auth is off" flash
     when auth_enabled is false (the previous symptom: logout button
     did nothing because /auth/me returned a synthetic admin no matter
     what).
   - Added a `.flash.info` style for the new neutral notice.

4. Sidebar logout used to call /auth/logout then `window.location
   .reload()`. With auth off that reload landed back on the synthetic-
   admin app and looked like nothing happened. It now redirects to
   /login.html in all states so the operator sees feedback (and the
   server-side messaging about auth being off) instead of a no-op.

Deploy notes for zampp1:
  - Set AUTH_ENABLED=true and a random SESSION_SECRET in the
    mam-api environment (e.g. /opt/wild-dragon/.env).
  - Restart mam-api.
  - First load of /login.html will auto-route to the setup form so
    you can create the first admin.
2026-05-27 02:47:09 +00:00
opencode
a86c1c72f9 fix(player): stitch S3 ranges around RustFS empty-body bug (#143)
RustFS returns empty bodies for ranged GETs whose start offset is past
~5.9 MB on single-file proxy MP4s. HEAD reports correct size, full GET
(`bytes=0-`) works, but `bytes=8179166-` comes back 206 + correct
Content-Range header with zero bytes. Confirmed via direct S3 probe
against broadcastmgmt.cloud/dragonmam (see scratch tests).

Workaround in mam-api `GET /api/v1/assets/:id/video` until the proxy
worker emits HLS (planned v1.2.1):

  - HEAD the object first to learn total size (also gives ETag /
    Last-Modified for conditional requests).
  - No-Range / unparseable-Range / pre-EOF requests \u2192 plain pipe.
  - Parsed `bytes=N-M` requests below RUSTFS_RANGE_SAFE_START
    (default 5_500_000) \u2192 direct ranged GET, RustFS handles fine.
  - Anything reaching into the broken zone \u2192 stream from offset 0,
    drop bytes below start, stop at end. Memory stays flat; extra
    bandwidth = (end+1 - requested-size) per seek.
  - Genuinely out-of-range \u2192 416 with Cache-Control: no-store so the
    browser doesn't poison its cache.

Also stashes (not yet wired up) the HLS pieces we'll need for the
follow-up: `segmentToHls` ffmpeg helper + `uploadDirectoryToS3`
worker s3 helper. Harmless additions; not referenced by any code path
yet.

Confirmed against the affected asset (a72aaa03-...): bytes=0-100k +
50% +100k native pass-through; 70% +100k and near-EOF previously hung
the browser, now stream correctly via the stitched path.

Refs #143.
2026-05-27 02:38:42 +00:00
opencode
04ce096e67 chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
  validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
  production React UMD instead of dev builds + in-browser Babel (#139,
  #122)
- Search wrapper gets role=search; global search input gets aria-label,
  role=combobox, aria-controls/aria-expanded/aria-activedescendant
  wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
  mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
  flex container — switched to flex:1 + min-height:0 (#131, #132,
  editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
  the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
  data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
  window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
  uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)

Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
  Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
  of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
  tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
  transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
  detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
  tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
  create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
  private + loopback hosts for non-admins, denies common service
  ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
  rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
  survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
  server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
  (ampp_sync_status / attempts / next_attempt_at + scheduler retry
  loop with exponential backoff) (#77)

Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)

Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
  Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
  migration tool to v1.3
2026-05-27 02:06:14 +00:00
64d739b40d feat(admin): unified Storage settings page with mount/bucket health diagnostics
- Collapses S3 + Growing-files nav into single 'Storage' section
- Adds GET /api/v1/storage/overview with fs/df probes + HeadBucket check
- MountHealthStrip shows green/red pills, free space, S3 latency
- Reuses existing S3SettingsCard + GrowingSettingsCard below health strip
2026-05-26 22:45:50 +00:00
opencode
1535bbaefa fix(web-ui): load js/bmd-card.js in index.html
The BMD card SVG renderer (window.BMDCards) was created in an earlier
session but never wired into index.html, so the new video-presence
indicator from a44d8bd was silently bailing at the !window.BMDCards
guard.  Loading it alongside the other helpers in /js/.
2026-05-26 22:16:19 +00:00
opencode
a44d8bd7c9 feat(admin): live video-presence indicators on cluster DeckLink ports
Adds per-port video signal state to the admin Cluster panel:

- New GET /cluster/devices/blackmagic/signal endpoint joins recorders by
  node_id+device_index and queries each active capture container's
  /capture/status (local: http://recorder-<id>:3001, remote: api_url/
  sidecar/<container_id>/status).  Returns receiving/connecting/lost/
  error/idle/no-recorder per port plus framesReceived and currentFps.

- bmd-card.js render() now accepts portSignals (Map or object) and
  overlays a colored dot on each BNC connector with pulse animation
  for receiving/connecting states.

- screens-admin.jsx Cluster panel polls the new endpoint every 5s,
  feeds the signal map into both the port chips (now show
  RECEIVING/CONNECTING/LOST + fps) and the BMD SVG card diagram
  rendered below them via a new BmdCardPanel component.

- styles-fixes.css adds bmd-card-* styles for the SVG diagram and
  bmd-port-signal --pulse animation.
2026-05-26 22:02:38 +00:00
d257a19d9d fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
mam-api /video endpoint:
- S3 InvalidRange (httpStatusCode 416) was being caught and returned as 500
  via next(err), which the video element treats as a fatal load error and
  freezes the player. Now we catch the specific 416 case, do a no-range
  HEAD-equivalent to learn the real file size, and return proper 416 with
  Content-Range: bytes */<total> so the browser can recover.

screens-asset.jsx — player health + buffer visualization:
- New states: playerState ('idle'|'loading'|'playing'|'paused'|'seeking'|
  'waiting'|'stalled'|'error'), playerError, buffered (array of {start,end}
  ms from HTMLMediaElement.buffered), stallStart, stallElapsedMs
- Wired video element events: onProgress, onWaiting, onStalled, onPlaying,
  onCanPlay, onCanPlayThrough, onSeeking, onSeeked, onError
- onError captures MediaError code+message into a console.error and the
  on-screen badge so freeze causes are now visible
- Status badge overlay (top-right of player): shows SEEKING / BUFFERING /
  STALLED / ERROR + elapsed seconds since the stall began
- PlaybackBar renders buffered ranges as translucent grey segments so you
  can see what the browser has loaded vs. what's still pending — makes
  seek-related freezes immediately obvious
2026-05-26 20:25:40 +00:00
f0f615688e release: add v1.1.0 ZXP artifact (Growing tab + visual system alignment) 2026-05-26 16:09:52 -04:00
a6f045b3d7 fix(node-agent): probe GPU via Docker API async at startup, cache result
Replaced sync execFileSync('docker') approach (no docker CLI in container)
with async Docker socket HTTP API calls:
- POST /containers/create with nvidia runtime + DeviceRequests
- POST /containers/:id/start
- Poll inspect until not running
- GET /containers/:id/logs, strip 8-byte frame headers, parse csv

probeGpusViaSmi() runs once at startup before the first heartbeat.
Result cached in _gpuCache; detectHardware() reads cache on every heartbeat.
Falls back to /dev/nvidia* scan if probe fails or runtime unavailable.
2026-05-26 18:28:03 +00:00
558c18e417 fix(node-agent): detect GPUs via docker run --gpus all ubuntu:22.04
nsenter approach failed (requires SYS_ADMIN in container).
nvidia-smi bind-mount failed (Alpine vs Ubuntu glibc incompatibility).

Working solution: spawn 'docker run --rm --gpus all ubuntu:22.04 nvidia-smi'
via the Docker socket. The NVIDIA Container Runtime injects nvidia-smi and
driver libs into any container with --gpus all, regardless of the base image.
ubuntu:22.04 is already cached on GPU nodes.

Result: GPU reported with name, memory_mb, driver_version — shows as BOUND
in the cluster UI.
2026-05-26 18:25:44 +00:00
5ff507b81b fix(node-agent): use nsenter to run nvidia-smi in host mount namespace
nvidia-smi bind-mount failed due to Alpine vs Ubuntu glibc incompatibility.
Fix: nsenter --mount=/proc/1/ns/mnt -- nvidia-smi runs in the host's mount
namespace where glibc and all NVIDIA driver libs are present.

Requires pid: host in docker-compose.worker.yml (already has network: host).
nsenter is provided by util-linux in Alpine — already in the image.

Falls back to direct nvidia-smi call (for glibc-based containers), then
to /dev/nvidia* file scan if all attempts fail.
2026-05-26 18:22:11 +00:00
726343db96 fix(node-agent): bind nvidia-smi for full GPU info (name, VRAM, driver)
index.js:
- detectGpusViaSmi(): runs nvidia-smi --query-gpu=index,name,memory.total,
  driver_version and parses the output into structured GPU objects with
  name, memory_mb, driver, device — the same fields the cluster UI uses
  to determine BOUND status
- Falls back to /dev/nvidia* file scan if nvidia-smi isn't available

docker-compose.worker.yml:
- Bind-mount /usr/bin/nvidia-smi and libnvidia-ml.so.1 from host into
  node-agent container (read-only). These are the minimum binaries needed
  for nvidia-smi to execute inside the container.
- Mounts are optional — Docker ignores them silently if paths don't exist
  (e.g. on nodes without NVIDIA hardware)
2026-05-26 18:19:23 +00:00
55ff2e717f feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
  (bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
  device paths

Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
  with online/offline color coding

Stat row: adds Capture ports total count card

Topology SVG: shows GPU count and BMD port count under each node label

Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 18:06:30 +00:00
e4d4c00f52 feat(proxy): VBR 500k-1M encoding for proxy generation
executor.js:
- transcodeVideo() now accepts videoMinRate, videoMaxRate, videoBufSize
- When set, passes -minrate/-maxrate/-bufsize to FFmpeg for ABR/VBR mode
- libx264 operates with per-scene quality variation within the envelope

proxy.js:
- Target average: 750k (gpu_bitrate_mbps=0.75)
- Min: 375k (50% of target), Max: 998k (~133%), Buffer: 2× max
- Gives effective range of ~500k-1M depending on scene complexity
- Log now shows VBR min-max-avg
- GPU fallback also passes VBR params
- Default videoBitrate changed from 10M to 750k in executor.js
2026-05-26 17:44:18 +00:00
03aa7a0673 fix(video): revert S3 redirect — RustFS rejects range+Origin; proxy with cache headers
S3 at broadcastmgmt.cloud (RustFS/openresty) returns 403 on range
requests that include an Origin header on presigned URLs. The HMAC
signature only covers 'host' in X-Amz-SignedHeaders, so the browser's
cross-origin Origin header breaks signature validation.

Reverted: /stream and /video no longer redirect to signed S3 URLs.

Fixed: /video now pipes through Node with:
  Cache-Control: private, max-age=3600
  ETag and Last-Modified forwarded from S3

This means the browser caches video segments for 1h. On seek the
browser checks its cache first — only uncached byte ranges hit the
server. Combined with the 1.5Mbps proxy (was 4Mbps), seeks should
be responsive for clips under ~10 minutes.
2026-05-26 17:40:02 +00:00
37247fdfea fix(video): direct S3 signed URL for streaming + proxy bitrate 1.5Mbps
- GET /assets/:id/stream now returns a signed S3 URL directly (4h TTL)
  instead of pointing to the /video pipe endpoint. Browser streams
  directly from S3 — no Node.js bottleneck, S3 handles range requests
  natively for smooth seeking.

- GET /assets/:id/video now redirects (302) to a signed S3 URL.
  Belt-and-suspenders: any code still calling /video gets redirected.

- proxy.js: default bitrate changed from 10Mbps to 1.5Mbps, audio
  default from 192kbps to 128kbps. DB settings already updated to
  1.5Mbps. Cuts proxy file size ~6x for the same quality content.
  Existing proxies need re-generation at new bitrate.
2026-05-26 16:57:37 +00:00
a03dd36f11 fix(premiere-plugin): hide growing-count badge until count > 0
The badge initially showed '0' before any poll completed. Toggling
display via JS expects an initial display:none so the badge does not
flash in the tab nav on first connect.
2026-05-26 16:40:47 +00:00
a03c85f08a feat: server-side filmstrip worker + fix scheduler crash + fix clip freeze
Root causes found:
1. Scheduler crashing every 15s: assets table has no error_message column.
   Fix: remove error_message from UPDATE in scheduler.js (#66 regression).

2. Clip freezing: client-side filmstrip seek loop runs on main thread,
   seeks same proxy the player is streaming → both stall → freeze.
   Fix: replace browser seek loop entirely with server-side FFmpeg worker.

3. No dedicated filmstrip worker: filmstrip was never pre-built server-side.

Changes:
- services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql
  Add filmstrip_s3_key TEXT column to assets table

- services/worker/src/workers/filmstrip.js (new)
  BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract
  28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON
  array to S3 at filmstrips/<assetId>.json, stores key in DB

- services/worker/src/workers/thumbnail.js
  Queue filmstrip job automatically after thumbnail completes

- services/worker/src/index.js
  Register filmstrip worker (concurrency=2), export filmstripQueue
  singleton, close it on SIGTERM

- services/mam-api/src/routes/assets.js
  - filmstripQueue added
  - POST /reprocess?type=filmstrip now supported
  - GET /:id/filmstrip returns signed S3 URL for JSON frames

- services/mam-api/src/routes/jobs.js
  filmstrip queue visible in Jobs UI

- services/web-ui/public/screens-asset.jsx
  Replace browser seek loop with fetch of /assets/:id/filmstrip
  → fetch S3 JSON → render frames. Zero browser-side video seeking.
  Right-click and Files tab re-generate via API endpoint.
2026-05-26 16:39:44 +00:00
564cf6b18f fix: thumbnail img uses signed URL from API; switch transcoding to CPU libx264
- FilesTab: fetch /assets/:id/thumbnail (returns signed S3 URL JSON),
  display the resolved URL in <img> instead of pointing directly at the
  endpoint which returns JSON not image bytes
- Transcoding: settings updated on ZAMPP1 to gpu_transcode_enabled=false,
  codec=libx264 — NVENC not available in worker container (no GPU passthrough)
  The proxy worker already has a CPU fallback but this prevents the
  unnecessary failed GPU attempt on every job
2026-05-26 16:27:27 +00:00
89645f160e fix(filmstrip): seeked event never fires at t=0; add per-frame seek timeout
Two bugs:

1. Frame 0 sets currentTime=0 but probe starts at t=0 after onloadedmetadata,
   so 'seeked' never fires (no position change). Promise hangs until the 15s
   global timeout kills the whole build. Fix: when currentTime is already at
   target (within 0.05s), call done() immediately without waiting for seeked.

2. Seeks into unbuffered regions of large MP4s can stall indefinitely.
   Fix: 3s per-frame timeout captures the current decoded frame and moves on,
   so a slow/stalled seek doesn't block the remaining 27 frames.
2026-05-26 16:21:00 +00:00
e9eeb84c5f fix(files-tab): remove inline video preview from proxy row 2026-05-26 16:10:04 +00:00
4f98f2b773 feat(asset): filmstrip right-click menu + Files tab
Filmstrip:
- Right-click on the filmstrip opens a context menu with
  'Re-generate filmstrip' and 'Re-generate proxy'
- filmstripKey state forces the build effect to re-run on demand
  without waiting for a streamUrl/totalMs change
- Context menu dismisses on click, contextmenu, and scroll

Files tab (replaces empty Versions tab):
- Proxy: status badge, S3 key path, inline video preview, re-generate button
- Hi-res master: status badge and S3 key path
- Thumbnail: status badge, S3 key path, inline thumbnail image, re-generate button
- Filmstrip: status badge, frame count, scrollable strip of first 14 frames,
  re-generate button (disabled while building)
2026-05-26 16:07:33 +00:00
b3c61134fc fix(filmstrip): remove crossOrigin=anonymous from probe video element
The /video endpoint requires session auth (requireAuth middleware).
crossOrigin='anonymous' strips cookies from the request → 401 → video
never loads → 15s timeout → filmstrip stays empty for all clips.

Same-origin video does not need crossOrigin for canvas drawImage — the
taint restriction only applies to cross-origin resources.
2026-05-26 16:03:26 +00:00
5edb4df35a fix(assets): missing closing }); on POST / route (syntax error) 2026-05-26 15:05:50 +00:00
07f8ffa6d5 feat: editor coming-soon bumper + embedded Premiere panel downloads
- Editor: overlay Coming Soon screen over NLE timeline (code preserved,
  bumper sits at z-index 100 with backdrop blur). Links to download
  ZXP and Windows installer directly from the bumper.

- Settings → Capture SDKs: new Premiere Panel section lists v1.0.0
  and v1.0.1 with ZXP + Windows Installer download buttons.
  Both releases embedded as static files in web-ui under /downloads/.

- nginx: /downloads/ location serves files as Content-Disposition
  attachment with 24h cache.

Files added:
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.0.zxp
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.0-windows-setup.exe
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.1.zxp
  services/web-ui/public/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe
2026-05-26 14:34:28 +00:00
8e0e94de3d fix: close all 24 open issues (#40–#94)
Bug fixes:
- #91: dockerApi() 10s socket timeout (Docker daemon hang)
- #77: await syncToAmpp() with .catch() — no longer fire-and-forget
- #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status
- #73: BullMQ orphan job cleanup on hard asset delete
- #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows
- #66: scheduler tick marks stale live assets (>2h) as error
- #63: migration 017 — partial unique index prevents concurrent live asset overwrite
- #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET
- #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy)
- #40: already fixed (All projects clears openProject)
- #64: already fixed (sourceType/needsProxy handled)
- #90: GET /jobs now includes DB jobs table (trim jobs visible in UI)
- #74: nginx Content-Type header preserved; multer 500MB file size limit
- #68: GET /upload returns in-progress ingesting assets
- #58: /stream and /video endpoints fall back to original file for all video types
- #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval
- #52: thumb-status and thumb-duration moved inside position:relative wrapper
- #50: ProjectCard gets onContextMenu handler with rename/delete menu
- #49: project context menu dismisses on contextmenu + scroll events

Features:
- #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset
  Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons

UI:
- Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher,
  and login — white logo pops on dark UI; inline style removed from login.html
2026-05-26 14:10:44 +00:00
602370be26 fix(worker): use bracket notation for @_ XML attribute property access
track?.@_currentExplodedTrackIndex is invalid JS syntax — @ is not a
valid identifier character. Replaced with track?.['@_currentExplodedTrackIndex']
so the worker process no longer crashes on startup.
2026-05-26 09:41:33 -04:00
3ebe5d6639 fix(users): invalidate sessions on password change (issue #94 bug 5) 2026-05-26 07:39:14 -04:00
6ee284e3f6 fix(auth): add brute-force rate limiting on POST /login (issue #94 bug 6) 2026-05-26 07:39:14 -04:00
bacdb9f49c fix(worker): close all Queue singletons + promotion intervals on SIGTERM (issue #94 bugs 4, 7, 10) 2026-05-26 07:38:08 -04:00
6eb98d866b fix(youtube-import): export proxyQueue singleton for clean SIGTERM shutdown (issue #94 bug 7) 2026-05-26 07:38:07 -04:00
cb0efdfdae fix(proxy): export thumbnailQueue singleton for clean SIGTERM shutdown (issue #94 bug 7) 2026-05-26 07:36:54 -04:00
a6c9529c50 fix(promotion): singleton proxyQueue; await promote(); return shutdown fn (issue #94 bugs 3, 4) 2026-05-26 07:36:08 -04:00
e289554e44 fix(trim): update jobs table status on complete/fail (issue #94 bug 2) 2026-05-26 07:35:28 -04:00
bec64e668d fix(conform): mark asset error on failure; scope asset lookup by project_id (issue #94 bugs 1, 9) 2026-05-26 07:35:13 -04:00
a0b7b42524 feat(db): add growing_retention_days setting (migration 015) 2026-05-26 07:27:23 -04:00
09e2987c14 feat(db): add growing_enabled column to recorders (migration 014) 2026-05-26 07:27:17 -04:00
ee0c2a12de Use HTML img tag for screenshot 2026-05-26 01:47:28 +00:00
782ff5b7b6 Try relative path for screenshot 2026-05-26 01:38:14 +00:00
a20b0d3fe3 Use absolute URL for screenshot in README 2026-05-26 01:36:24 +00:00
f420584e1a Move feature overview to README with screenshot 2026-05-26 01:34:18 +00:00
0d85899627 Add home dashboard screenshot 2026-05-26 01:28:44 +00:00
be9ae32a3b Add feature overview documentation 2026-05-25 21:24:48 -04:00
7fc502513e fix(#78): GET /assets — include_archived filter now independent of status filter 2026-05-25 19:29:23 -04:00
2e1ac72585 fix(#79): proxy worker respects live/ingesting status on error 2026-05-25 18:36:39 -04:00
fba671ad40 fix(#53): show error banner with retry when loadData() rejects 2026-05-25 17:42:39 -04:00
33c82cab1a fix(#38,#54): fix apiFetch Content-Type header order; fix normalizeAsset seed hash 2026-05-25 17:42:06 -04:00
75c23448b4 fix(#65): GET /schedules returns 400 for unknown status query param 2026-05-25 17:40:58 -04:00
548c2ab8a4 fix(#72,#59): remove nginx /health stub — API endpoint proxies through correctly 2026-05-25 17:38:40 -04:00
15b4d45375 fix(#48): add type:module to mam-api package.json 2026-05-25 17:37:56 -04:00
4c8c3b72bb fix filmstrip: append probe to DOM, fix race condition, add 15s timeout 2026-05-25 11:26:02 -04:00
7ea3a235da fix: filmstrip — fetch video chunk as Blob URL, append probe to DOM, add timeout 2026-05-25 11:21:38 -04:00
0481fb3ecf fix: filmstrip probe video — append to DOM, fix src/handler race, add timeout 2026-05-25 11:14:55 -04:00
37c406bf4d fix filmstrip: use hls.js for HLS stream frame capture, not only direct streams 2026-05-25 09:30:40 -04:00
b345f5f6a4 fix editor: use assetsRef to avoid stale closure in handleExternalDrop 2026-05-25 09:29:05 -04:00
OpenCode
87f14b7c71 Fix asset filmstrip and editor UX 2026-05-25 05:14:36 +00:00
c501d88c63 Auto refresh library after ingest 2026-05-25 01:13:19 -04:00
78539ec8b0 Fix editor timeline interactions 2026-05-25 01:10:45 -04:00
de895dd7f8 Fix library refresh behavior 2026-05-25 01:08:38 -04:00
3dad82d992 fix(editor): drag interactions, undo history, overflow clipping
Four critical fixes:
- Remove overflow:hidden on tlRef so Timeline.init's scroll survives re-renders
- Don't call _renderClips() inside mousedown (was destroying event target mid-drag)
- Use refs for undo history to eliminate stale closure in onClipsChanged callback
- Change .tl-clip-area overflow:hidden to overflow:visible so pointer events reach clip edges
2026-05-24 21:21:23 -04:00
4673efac6a fix(editor): setScale, hand pan, sort comparator, playhead sync, rename/delete, track selector 2026-05-24 21:03:12 -04:00
721f847b28 fix: remove openreel editor; fire df:assets-changed on upload/ingest complete 2026-05-24 20:36:04 -04:00
c36c732f47 fix: comment out editor service in docker-compose to unblock deployment 2026-05-24 18:58:22 -04:00
60e306d1db fix(hls): retry on playback failure with exponential backoff 2026-05-24 16:52:04 -04:00
ce31a45124 feat(editor): Phase 1 core NLE editor React SPA rewrite 2026-05-24 16:20:38 -04:00
7189df7957 docs: add NLE editor React polish plan (phases 1-3) 2026-05-24 14:53:56 -04:00
f21157f3c7 fix: refresh bin counts after asset move
Dispatch df:bins-changed custom event from onBinDrop and
AssetContextMenu.moveToBin so the bin rail counts update
immediately after moving an asset into a bin.
2026-05-24 14:50:22 -04:00
a5ab57d144 fix: add missing > to close bin rail div opening tag
Missing > after title attribute caused Babel parse error,
preventing Library component from being registered globally.
2026-05-24 14:46:36 -04:00
0ebc7ef777 fix: use window.RenameProjectModal via React.createElement
RenameProjectModal is exported to window from screens-projects.jsx,
so Library screen must reference it via window object and use
React.createElement instead of JSX syntax.
2026-05-24 14:30:22 -04:00
d94ed00312 fix: apiFetch headers spread, droppable highlight, project rename, color stability, orphaned api.js removal
- Fix apiFetch headers spread bug (custom headers overwrote Content-Type)
- Track per-bin hover state for droppable highlight
- Refresh project rail after rename from Library screen
- Use ID-hash for project colors instead of array index
- Remove orphaned js/api.js (563 lines, never loaded)
- 'All projects' rail item clears openProject filter
- Add project boundary guard to drag-and-drop bin moves
- Stabilize refreshAssets useCallback with empty deps
- 'Last 24h' filter now actually filters by created_at
2026-05-24 14:20:00 -04:00
af905cf936 fix: bin creation 500 error + add drag-and-drop + project rename
- Fix 500 error when creating bins: missing updated_at column on bins table
  (migration 013 adds the column, schema.sql updated)
- Add drag-and-drop support for moving asset cards/list rows onto bin rail items
  with visual droppable highlight
- Add right-click context menu on project rail items (Rename/Delete)
- Expose RenameProjectModal via window so Library screen can reuse it
- Bins context menu already existed — was hidden by the 500 error
2026-05-24 13:27:24 -04:00
c312991bac feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
  conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
  trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
  ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
  (accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
  presets table, and architecture overview
- #24 PR merge: verified mergeable

All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
77130ac769 feat(server): temp segments cleanup task
Hourly cron that deletes expired temp_segments from S3 and DB.
Implements issue #34. Registered alongside scheduler in index.js.
2026-05-24 12:43:08 -04:00
a016175fc8 feat(db): migration 012 — advanced features schema
Add 'trim' to job_type enum, create temp_segments table with
expiry/job/asset indexes, and add conform_source_sequence_id
to assets for lineage tracking.

Closes #33
2026-05-24 12:42:22 -04:00
543248b8c2 Merge remote-tracking branch 'origin/feat/premiere-installer' 2026-05-24 12:03:46 -04:00
eadafffb18 fix(premiere-plugin): v1.0.1 — actually load + connect under CEP 12
End-to-end debugging against a live Premiere Pro 2025 + auth-enabled mam-api
surfaced four real bugs that made v1.0.0 install cleanly but never load,
plus the missing auth flow. All four are fixed and the panel is verified
connected (status dot green, Reconnect button shown, project list populated).

  - manifest.xml: a comment in the <Resources> block contained "--" (inside
    "--enable-nodejs"/"--mixed-context"), which is illegal per the XML spec.
    CEP 12's strict parser logged
        ERROR XPATH Double hyphen within comment
    and skipped the panel entirely. Comment rewritten without double hyphens.

  - manifest.xml: lacked the Version="X.Y" attribute on <ExtensionManifest>
    and used a non-standard AbstractionLayers/empty <ExtensionList/>
    structure. CEP rejected it with
        Unsupported Manifest version ''
    Manifest rewritten to the standard CSXS 7.0 schema (ExtensionList +
    DispatchInfoList + RequiredRuntimeList), matching the working AMPP
    panel template.

  - main.js: re-declared `const csInterface = new CSInterface()` at top
    level even though CSInterface.js already declared the same binding.
    CEP 12 shares script-realm lexical scope across <script> tags, so the
    second const threw
        Identifier 'csInterface' has already been declared
    The throw fired before setupEventListeners(), so the Connect button's
    click handler was never attached. This is the root cause of the
    original "clicking Connect does nothing" symptom; everything else was
    secondary. Removed the duplicate declaration; main.js now uses the
    binding from CSInterface.js.

  - No auth support against AUTH_ENABLED=true servers. mam-api supports
    Bearer tokens (POST /api/v1/tokens), so added:
      • API token input field (password-masked) next to Server URL
      • localStorage persistence on every keystroke
      • window.fetch monkey-patch that injects
          Authorization: Bearer <token>
        on every request whose URL starts with the configured server.
        Signed S3 download URLs are NOT touched.

Drive-by fixes that came out of the same debugging pass:
  - Server URL input listener was 'change' (fires on blur); switched to
    'input' so typing-then-clicking-Connect immediately commits.
  - restoreSettings() now strips trailing slashes from the stored URL so
    older saved values like 'http://host/' stop producing //api/v1 404s.
  - CSS selector `input[type="text"].server-url` didn't match the new
    password input → the token field was unstyled and effectively invisible.
    Generalized to `input.server-url`; restructured the connection bar into
    `.connection-controls--stacked` (flex column) of two `.server-input-row`
    rows so two input fields fit cleanly.
  - Build scripts now parse ExtensionBundleVersion from both element form
    (<ExtensionBundleVersion>X</...>) and attribute form
    (ExtensionBundleVersion="X"), since the manifest rewrite switched
    schemas.

Version bumped 1.0.0 → 1.0.1. New artifacts committed at
services/premiere-plugin/build/releases/v1.0.1/ (.exe 2 MB, .zxp 35 KB).
v1.0.0 left in place so editors who downloaded it can verify they're on
the broken version.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:24:10 -04:00
a6d789279c Merge pull request 'fix(jobs): real cancel for active jobs + multi-threaded thumbnail worker' (#29) from fix/jobs-cancel-and-concurrency into main 2026-05-23 17:23:52 -04:00
91325a4267 fix(jobs): real cancel for active jobs + multi-threaded thumbnail worker
DELETE /jobs/:id was throwing "404 not found" when the operator tried to
cancel a running job. BullMQ refuses job.remove() while a job is in the
active state; the route caught that error and fell through to the
404 branch, which was misleading because the job actually exists — the
queue was just refusing to drop it from under the worker.

Fix:
- Detect 'active' state explicitly and call moveToFailed(err, '0', false)
  first. Token '0' bypasses the per-worker lock check (the operator-side
  cancel doesn't hold the worker lock). That transitions active -> failed
  and frees the queue's concurrency slot.
- If moveToFailed itself fails (lock owned by a live worker), fall back
  to job.discard() so at least the result is thrown away.
- If remove() then fails (stalled, broken state), drop the job's Redis
  key directly via queue.client. Last-resort obliteration.
- Stop swallowing getJob() errors — if Redis is sad, surface it via
  next(err) instead of returning a misleading 404.
- Return { cancelled: true } when the job was active, so the client
  can show "Cancelled" rather than "Removed" in any future toast.

While here: thumbnail jobs now run with concurrency 4 by default
(proxy 2, conform 1, import 1 unchanged). Every queue defaulted to
concurrency 1 before, so a single stalled job blocked the entire queue.
All three are overridable via PROXY_CONCURRENCY / THUMBNAIL_CONCURRENCY
/ CONFORM_CONCURRENCY env vars for nodes with more headroom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:23:07 -04:00
2b85fb49df Merge pull request 'fix(jobs): cancel running + delete failed jobs so the queue can be unstuck' (#28) from fix/jobs-cancel-stuck into main 2026-05-23 16:54:50 -04:00
eb6c723713 fix(jobs): cancel running + delete failed jobs to unstick the queue
The Jobs page only exposed a delete button for queued + done jobs, so a
stalled-active job (worker died holding a BullMQ concurrency slot) had
no way out from the UI. Operators were watching the queue back up
behind a single stuck thumbnail job with no kill switch.

- Running jobs now show a "Cancel" button (red text). Confirm copy
  spells out that the worker may run a few seconds longer in the
  background but the queue slot frees up immediately.
- Failed jobs now show the X icon for delete in addition to the
  existing Retry button.
- Both routes hit the same DELETE /jobs/:id endpoint; BullMQ's
  job.remove() works on any state including stalled-active.
- handleDelete takes an optional mode ('cancel' | 'delete') only to
  customise the confirm prompt and error toast wording.

Right-aligned the action cell so the Retry/Cancel/Delete buttons sit
flush right like the rest of the table's actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:54:05 -04:00
6322b61a04 Merge pull request 'chore(web-ui): delete legacy standalone HTML pages; SPA is the only entry' (#27) from chore/cleanup-legacy-html into main 2026-05-23 16:49:39 -04:00
ff2865b5d8 chore(web-ui): delete legacy standalone HTML pages; SPA is the only entry
Before this commit /public had two parallel UIs: the React SPA (index.html
+ screens-*.jsx) and a stack of pre-SPA standalone pages (home.html,
recorders.html, jobs.html, ...). The SPA replaces every standalone page,
nothing in the .jsx tree links to them, and the only outside references
were login.html redirecting to home.html and the nginx fallback pointing
at home.html.

Delete 16 standalone pages (~9.2k lines of dead markup, ~430KB on disk):
  _primitives-smoke.html  api-tokens.html  capture.html  cluster.html
  containers.html         edit.html        editor.html   home.html
  jobs.html               player.html      projects.html recorders.html
  settings.html           tokens.html      upload.html   users.html

Keep:
  index.html  — the React SPA shell
  login.html  — the sign-in / setup screen

Wire the redirects to the SPA:
- login.html post-signin: home.html -> /
- nginx try_files fallback: /home.html -> /index.html

After this, sign-in lands the operator on the real React app instead of
the stale 2025-era home page. The Editor screen continues to embed the
separate editor service via the /editor/ nginx proxy (unaffected).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:48:38 -04:00
53049d1c4d Merge pull request 'feat(auth): bounce to /login.html on 401 so AUTH_ENABLED=true gives a real login' (#26) from feat/auth-login-redirect into main 2026-05-23 16:41:25 -04:00
bec4bfaf31 feat(auth): bounce to /login.html on any 401 from the api wrapper
apiFetch now redirects to /login.html when the server returns 401, so
flipping AUTH_ENABLED=true on mam-api gives the user the login screen
instead of a half-loaded app that silently failed to fetch /auth/me.

While AUTH_ENABLED=false the server's /auth/me still returns a synthetic
200 user, so this branch is dormant — safe to deploy ahead of the env
flip on the server. After the flip the operator visits /login.html
(directly or via auto-redirect), runs the "Create admin account" flow
once, and lands back on the SPA with a real session.

Guards against a redirect loop if login.html itself somehow lands here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:40:45 -04:00
0537378d82 Merge pull request 'feat(schedule): right-click menu + drag-to-resize on EPG event blocks' (#25) from feat/epg-resize-and-ctxmenu into main 2026-05-23 16:34:48 -04:00
3ffffd5b32 feat(schedule): right-click menu + drag-to-resize on EPG event blocks
Right-click any event block to open a context menu (Edit, Cancel,
Copy schedule ID, Delete) — actions per status mirror the List view so
the two surfaces stay in lockstep. Menu is viewport-clamped and
dismisses on outside click / scroll, same pattern as the asset menu in
the Library.

Drag-to-resize works for pending schedules only (the schedules PUT
rejects edits to running rows, and terminal statuses are read-only):
- Drag the left edge to move the start time
- Drag the right edge to move the end time
- Drag the body to shift the whole block in time
All gestures snap to 15-minute increments to match the new-schedule
click snap. Minimum duration is clamped to 5 minutes; the block clamps
to the visible day on both edges. While dragging the title shows the
preview range ("Start time → end time") and the block lifts with a
project-tinted shadow.

A short pointer click (< 4px travel) still opens the edit modal — the
click and drag share the same pointerdown so the operator never has
to know which gesture they made first.

Implementation: replaces the <button> block with a <div> hosting three
zones (left handle / body / right handle). Pointer events with
setPointerCapture so drags survive losing the cursor over the block,
and pointerup demotes back to click if travel was below threshold.
Optimistic local update on resize, PUT /schedules/:id with just the
two changed time fields, refetch to reconcile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
d1fcfcc8fd chore(premiere-plugin): commit v1.0.0 installer artifacts
Drops dragonflight-premiere-panel-1.0.0-windows-setup.exe (2 MB) and
dragonflight-premiere-panel-1.0.0.zxp (35 KB) at
services/premiere-plugin/build/releases/v1.0.0/ so the binaries have
stable URLs on the forge without needing a separate release artifact
flow.

Heads-up: this commits 2 MB of binary into git history. Future bumps
should use Forgejo Releases (release assets are external to git history
and easy to delete) rather than another commit under releases/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:30:39 -04:00
97f08b32de ui(jobs): widen Time + Progress columns, narrow Node + Priority
Time was clipping the full "done May 22 · 2:23 PM · 6h ago" string on
terminal-state rows; Progress's bar felt cramped next to the percent.
Node carries only "primary" / "—" so it can shrink, and Priority's
"normal" / "high" badge doesn't need 80px either. Net widening absorbed
by the flexible Asset column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:27:13 -04:00
9a6ae3b786 fix(jobs): backfill asset_name from DB so non-YouTube jobs show their asset
The Jobs screen only displayed an asset name when the enqueueing code
stuffed assetName into the BullMQ job data. YouTube imports did that;
upload-triggered proxy/thumbnail jobs didn't — so everything except
YouTube showed em dashes in the Asset column.

Fix it centrally: after we collect jobs from BullMQ, look up names
in one bulk SELECT against the assets table for any job that has an
assetId but no asset_name. Applies to /jobs, /jobs/:id, and the SSE
events stream. Lookup failures fall through silently rather than
500-ing the whole list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:23:29 -04:00
8aece9cbc4 fix(premiere-plugin): make build pipeline portable to Windows PowerShell 5.1
End-to-end verification on a fresh Windows machine surfaced three issues:

1. pwsh isn't installed by default — Windows ships powershell.exe (5.1).
   Switched all script invocations + docs from `pwsh` to `powershell`.
2. .NET's strict XML parser rejects manifest.xml because the <Resources>
   comment legally contains `--` (inside `--enable-nodejs`/`--mixed-context`
   CEF flag names). Switched build-installer.ps1 to regex extraction,
   matching what build-zxp.mjs already does.
3. winget installs Inno Setup 6 to %LOCALAPPDATA%\Programs by default, not
   Program Files (x86). Added the user-scope path to the ISCC.exe fallback
   list.

Verified: `powershell -File build-all.ps1` produces both artifacts —
dragonflight-premiere-panel-1.0.0.zxp (35 KB, signature valid)
dragonflight-premiere-panel-1.0.0-windows-setup.exe (2 MB).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:22:46 -04:00
5699cff4d0 Merge pull request 'feat(schedule): EPG stylesheet + impeccable context (PRODUCT / DESIGN.md)' (#23) from polish/schedule-epg-styling into main 2026-05-23 16:20:48 -04:00
5882c68217 feat(schedule): EPG stylesheet + impeccable context (PRODUCT/DESIGN.md)
The EPG JSX components in screens-ingest.jsx ship with the YouTube branch
but the matching stylesheet got lost during the parallel-branch shuffle.
This adds the missing .epg-* block to styles-rest.css and replaces the
dead .cal-* (month-calendar) rules left over from the previous design.

What the styles cover:
- .epg-page / .epg-toolbar — top-level flex layout + date nav row
- .epg-status — sticky "on air" strip with pulse halo on the live dot
- .epg / .epg-corner / .epg-gutter / .epg-canvas-head / .epg-canvas —
  the 2x2 sticky grid (top ruler + left gutter both sticky)
- .epg-ruler / .epg-ruler-tick — hour ticks
- .epg-row + .epg-block + .epg-block.live/.failed/.past — event blocks
  with project-color 4px inner bar (no side-stripes; impeccable ban)
- .epg-now / .epg-now-pip — vertical hot-red now-line with broadcast glow
- .epg-week + .epg-week-day — stacked 7-day sections for week view
- .epg-empty — recorder-less / loading empty state

Also adds PRODUCT.md and DESIGN.md so future design passes have the
context files the impeccable skill requires. Both drafted from the
existing codebase (tokens, screen patterns) rather than synthesised
from a prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:19:25 -04:00
0ff2625876 fix(premiere-plugin): remove broken Inno Setup [Code] heuristic
The Premiere-running check passed a Boolean to Exec's var ResultCode (Integer)
parameter — Pascal type error. The block also did nothing useful: it only
checked but never warned or prompted. Drop it. {InstallDelete] of the legacy
folder still works through the Tasks checkbox + Check function.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:18:19 -04:00
c0d1251c1f fix(tokens): add missing showCalc state — page was crashing on render
The Tokens screen referenced showCalc / setShowCalc in the Cost calculator
button and modal but never declared the state hook, so the component
threw ReferenceError on mount and rendered blank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:18:19 -04:00
9266a1d471 fix(premiere-plugin): correct zxp-sign-cmd version + promise API; commit generated signing cert
The initial pass referenced zxp-sign-cmd@0.2.2 which never shipped (latest
is 2.0.0) and used the v1.x callback API. v2 is promise-based — rewrote
build-zxp.mjs accordingly.

Also commits the freshly-generated self-signed cert + passphrase from the
first local build run. From now on every build reuses these so Adobe's
ZXP signature-continuity rule is satisfied across versions.

Verified end-to-end: `npm install && node build-zxp.mjs` produces
dist/dragonflight-premiere-panel-1.0.0.zxp (34.7 KB), signature verifies,
cert valid until 2051.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:17:31 -04:00
f874009329 feat(premiere-plugin): ZXP + Windows installer build pipeline
Replaces the manual robocopy / install-windows.ps1 flow with two real
distributable artifacts:

  - dragonflight-premiere-panel-<version>.zxp          (Mac + Win)
  - dragonflight-premiere-panel-<version>-windows-setup.exe (Win)

The Windows installer copies the bundle to %APPDATA%\Adobe\CEP\extensions,
sets PlayerDebugMode=1 for CSXS 8..13, registers an uninstaller, and
offers to remove any legacy com.wilddragon.mam.panel folder so editors
don't end up with duplicate panels.

The .zxp is signed with a self-signed cert generated on first build and
committed to build/cert/ so signature continuity is preserved across
builds (Adobe rejects ZXP upgrades with a different cert fingerprint).

Also migrates the CEP bundle ID from com.wilddragon.mam.panel to
net.wilddragon.dragonflight.panel to match the wild-dragon -> dragonflight
repo rename. Manifest, .debug, CSInterface.js, install docs, and the
growing-files quickstart all updated.

build/ is normally swept by the root .gitignore; added an explicit
negation so the packaging pipeline stays tracked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:13:20 -04:00
9ad88e4df4 feat(ingest): YouTube importer — paste link, asset travels normal pipeline
Adds Ingest → YouTube. UI takes a URL + project, API enqueues a BullMQ
"import" job, worker shells out to yt-dlp, lands the MP4 in S3 at the
same originals/{assetId}/... path uploads use, then hands off to the
existing proxy queue. Imported assets share one lifecycle with uploads
from that point on.

Worker container picks up yt-dlp + python3 (apk on alpine, apt on the
GPU variant). The new 'import' queue is registered in jobs.js so it
appears in the Jobs SSE stream and retry/delete work for free.

Spec: docs/superpowers/specs/2026-05-23-youtube-importer-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:05:41 -04:00
7a2710dc9a docs: design spec for YouTube importer
Adds a paste-URL ingest path under Ingest → YouTube. Worker hosts
yt-dlp, downloads to S3, then hands off to the existing proxy +
thumbnail pipeline so imported assets share one lifecycle with uploads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:04:28 -04:00
674dccca4e Merge pull request 'fix(web-ui): CSS must-revalidate so deploys aren't masked by browser cache' (#22) from fix/css-cache-revalidate into main 2026-05-23 15:41:17 -04:00
f525506718 fix(web-ui): css must-revalidate so deployed styles are picked up immediately
Nginx was serving css with `expires 1y; Cache-Control: public, immutable`,
which combined with version-less <link href="styles-rest.css"> meant every
browser permanently pinned whatever stylesheet it cached first. Users were
seeing pre-polish-round-2 CSS even after the new image was deployed —
the calendar grid rendered as a vertical stack of weekday names because
the .cal-* rules didn't exist in the cached file.

Move css into the same bucket as js: must-revalidate via ETag. Fonts,
icons, and raster assets stay in the immutable 1y bucket since they don't
change between deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:40:26 -04:00
908cf8a62d Merge pull request 'jobs: show completion timestamp for done/failed jobs' (#21) from polish/jobs-completed-stamp into main 2026-05-23 15:29:37 -04:00
0551512fef feat(jobs): show absolute completion timestamp for done/failed jobs
The Time column now anchors on the wall clock when a job is in a terminal
state — "done 2:23 PM · 5m ago" / "failed May 22 · 14:24 · 1h ago" — so the
operator can correlate with logs and other timestamps without hovering.
Queued/running jobs keep the relative-only format since their timestamp is
constantly moving. Widen the column to 180px to accommodate the longer label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:26:24 -04:00
e8299fb9f6 polish: live refresh, schedule calendar, jobs times, real sidebar user (#20) 2026-05-23 15:18:55 -04:00
6a1d271576 feat(ui): polish round 2 — live refresh, schedule calendar, jobs times, real sidebar user
- recorders: dispatch df:recorders-changed on create/start/stop/delete so the
  list updates immediately instead of waiting for the 10s poll tick
- library: poll every 4s while any asset is live/processing (15s otherwise) and
  listen for df:assets-changed so a stopped recorder's LIVE badge drops and
  the thumbnail appears without a manual refresh
- auth: synthetic /auth/me (AUTH_ENABLED=false) now uses LOCAL_OPERATOR / USER /
  USERNAME instead of hardcoding "Admin", and flags synthetic:true
- shell: Sidebar takes `me` as a prop, drops the misleading "Admin" fallback,
  and surfaces an "auth off" hint when the response is synthetic
- jobs: replace the always-empty ETA column with a Time column that shows
  queued/started/done/failed N ago (full timestamp on hover); widen column
- schedule: new month-calendar view (default) with events plotted on day cells
  by status; clicking a day pre-fills the new-schedule modal with a 30-min
  window on that day; List view kept behind a toggle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:52:04 -04:00
7e64675aa5 fix: settings S3 surfaces fetch errors; recorder signal dot pulses
- screens-admin.jsx S3SettingsCard: when /settings/s3 fails, log to
  console and surface the message in the existing SettingsMsg banner
  instead of silently returning empty fields. Also logs the response
  payload on success so the next "endpoint blank" report is easier to
  diagnose. (closes part of #15)
- screens-ingest.jsx recorder row: wrap the signal value in a dot+text
  pair; add CSS so the dot pulses green when status=receiving and
  matches the value color otherwise. The pulse is the kind of cue the
  Live signal column was missing per #2.
2026-05-23 13:19:48 -04:00
2515258dd4 style(home): launcher styles + sidebar brand-logo treatment
Adds .launcher, .launcher-tile, .launcher-hero, .launcher-grid styles
plus .brand-logo replacement for the gradient-D mark in the sidebar.

The shipped img/dragon-logo.png has a light-gray background; we use
mix-blend-mode: screen so the black dragon silhouette sits on the
dark theme without showing the gray box, and a soft accent glow under
the hero version. Smaller sidebar version uses the same trick.
2026-05-23 10:56:10 -04:00
ccbebe172d feat(shell): add Dashboard nav entry; swap fake "D" mark for real logo
The sidebar header used a gradient "D" tile as a placeholder. Now it
uses the actual dragon-coiled-D logo so the brand reads consistently
between the launcher hero and the chrome.

Also adds a 'Dashboard' nav item directly under 'Home' so the
operations view is one click away.
2026-05-23 10:54:36 -04:00
74fc8323f0 feat(app): route dashboard separately from home; add to crumbs map
The launcher (Home) and the operations view (Dashboard) are now
distinct routes. Home is the landing page; Dashboard is reached from
the sidebar or from the launcher's "Open dashboard" tile.
2026-05-23 10:53:31 -04:00
740ab31f8c feat(app): wire the new Dashboard route alongside the Home launcher
home → launcher (big-button entry into each section)
dashboard → operations view (was Home; metrics, recent activity, queue)

Crumb labels updated so Home stays one level (just the wordmark) and
Dashboard gets its own breadcrumb.
2026-05-23 10:48:42 -04:00
72fc9cb755 feat(home): restore launcher home page; move current home to Dashboard
The original first-version home page (big-button launcher with the
Dragonflight wordmark) is back at /. The Frame.io-style metrics +
recent-activity layout we've been treating as "home" is now the
Dashboard, reachable from the sidebar and from the launcher's
"Open dashboard" button.

- Renames existing Home → Dashboard (all the cards, sparklines, live
  feed, job-queue, cluster mini-list are unchanged).
- New Home component: hero with the dragon-coiled-D logo (existing
  img/dragon-logo.png), wordmark "DRAGONFLIGHT", a tag line, and 5
  big tiles (Library, Recorders, Editor, Jobs, Settings) plus a
  smaller Dashboard tile. Live cluster + recorder status pip at the
  bottom mirrors what's in the topbar.
- The launcher pulls /metrics/home so the tile counts ("34 assets",
  "0 live", "0 running") reflect reality.
2026-05-23 10:48:06 -04:00
7a6296585c fix(asset): show 'Generate proxy' CTA when an asset has a hi-res
master but no browser-playable proxy

Previously the player's "Retry processing" button only appeared for
assets in status='error'. Old recorder captures (e.g. the archived
'sRT Test_…' clips from May) live as status='archived' or 'ready' with
original_s3_key set but proxy_s3_key null. The /stream endpoint
correctly returned {url: null, reason: 'no_proxy'} for those, but the
player just showed "Preview not yet available" with no path forward —
which reads as "ingest worked, won't play, no idea why."

Two changes:

1) Capture {reason, has_source} from /stream so the UI can tell why
   playback isn't available.

2) Render a "Generate proxy" button (using the existing
   POST /assets/:id/retry endpoint, which the backend now accepts for
   any asset with original_s3_key but no proxy_s3_key) whenever the
   stream lookup returned no_proxy and the source exists. Original
   error-status retry path is preserved.

Closes the visible half of #1 — the user can now self-recover proxy-
less clips from the library without DB surgery.
2026-05-23 10:30:42 -04:00
1afb150237 feat(assets): cleanup-live-orphans + retry handles non-error states
Two changes for issue #7 (HLS cleanup + orphan reaper) and the user's
"SRT clips ingest but won't play" complaint:

1) New POST /assets/cleanup-live-orphans — lists every directory under
   /live/<uuid>/ and deletes the ones whose UUIDs don't match an asset
   row. These accumulate when a recorder crashes mid-capture: the live
   HLS dir is created but no asset is ever finalized in the DB, so the
   files just sit on disk forever.

2) POST /assets/:id/retry now also works for assets that are 'ready'
   or 'archived' but have no proxy_s3_key. The original behavior (only
   re-queue when status='error') made it impossible to re-generate a
   proxy for older recorder captures that landed without one — the
   user could see a thumbnail in the library but the player would just
   show "Preview not yet available" with no retry path.
2026-05-23 10:28:42 -04:00
508e978fe5 fix(worker): route SVG (and other image assets) through the image-poster
path instead of failing the video transcode

Previously IMAGE_CODECS contained the raster ffprobe codec names ('png',
'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls') but not 'svg'.
An SVG-as-asset (e.g. an architecture diagram dragged into a project) was
correctly tagged media_type='image' in the DB but ffprobe reported its
codec as 'svg', which fell through to the video branch, found
durationMs===null, and died with 'Empty or truncated source: codec=svg,
resolution=0x0'. That clogs the failed-jobs list with red rows that have
nothing to do with broken captures.

Two fixes here:

1) Add 'svg' to IMAGE_CODECS so the existing transcodeImage()/poster
   path handles it.

2) Also bail to the poster path when the asset row itself says
   media_type='image', even if ffprobe didn't return a codec name we
   recognize (defensive — catches future formats like AVIF without
   requiring an explicit catalog update).

Closes part of #13.
2026-05-23 10:26:59 -04:00
d07fb13401 ui: search + right-click menu polish so they read as real controls
- Topbar search now sits on bg-2 with a stronger border, subtle inset
  highlight, and a hover state. Search icon and kbd hint get more
  contrast. Focus state lifts the field with a soft accent ring.
- Search results dropdown gets a slightly inset header look so the
  list reads as connected to the field.
- Right-click context menu (ctx-menu) gets a stronger background,
  a tighter section header, separator color tuning, and a soft
  outline so it feels like a popover instead of floating text.
2026-05-23 09:53:17 -04:00
a8a2061eec fix(asset): comment composer shows real user from ZAMPP_DATA.ME, removes dead add-reviewer button 2026-05-23 09:15:20 -04:00
14d689aaf3 fix(shell): sidebar user name/avatar/role from ZAMPP_DATA.ME instead of hardcoded ZG 2026-05-23 09:13:37 -04:00
eed4180b70 feat(data): fetch /auth/me on load, store ZAMPP_DATA.ME with name/initials/role 2026-05-23 09:12:40 -04:00
854775e322 fix(admin): removeNode URL bug, container empty-state text, PasswordResetModal replaces prompt() 2026-05-23 09:07:56 -04:00
004bdd0778 fix(projects): RenameProjectModal replaces prompt() 2026-05-23 09:02:23 -04:00
6fe5f7d450 fix(library): RenameAssetModal replaces prompt(), inline bin name input replaces prompt() 2026-05-23 09:02:09 -04:00
claude
13906cd0fe feat(library,bins): inline bin creation in the left rail
Library's Bins section now always renders (not just when bins exist)
with a + button that prompts for a name and POSTs /api/v1/bins with
the open project's id. Bins re-fetch on project change so the rail
shows project-scoped bins when a project is open, or global view
otherwise.

Bins list now hydrates from local state instead of stale ZAMPP_DATA
so newly-created bins appear without a full reload. Without an open
project the + button is dimmed with a helpful tooltip — "Open a
project to create a bin".
2026-05-23 04:27:23 +00:00
claude
7170a9945c polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
  opens a modal that PUTs /schedules/:id with new name/times/recurrence
  (recorder reassignment is intentionally locked — delete + recreate
  to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
  catalog (ingest, growing-files, scheduler, library + comments,
  jobs, settings, cluster), accurate ports, refreshed architecture
  diagram, ops scripts inventory
2026-05-23 04:26:03 +00:00
claude
7700548dee test: deploy/api-smoke.sh — exercises every API surface
Walks GET endpoints for auth, projects, assets, recorders, jobs, bins,
users, groups, cluster, settings, metrics, schedules, sdk, and the
freshly added comments routes. Deep-links one asset + one recorder by
ID so per-asset endpoints (stream, thumbnail, comments) get coverage.

Prints HTTP codes inline and exits non-zero on any failure. Treats
2xx/3xx as pass; 400/401 also pass since they indicate the route
exists and auth/validation is working as designed.

Usage:
  deploy/api-smoke.sh                      # localhost:47432
  API=http://10.0.0.25:47432 deploy/api-smoke.sh

NewRecorderModal: hardened ZAMPP_DATA hydration with defensive
defaults so first-load timing doesn't blow up the modal.
2026-05-23 04:24:10 +00:00
claude
90a9e4361a feat(comments): persistent frame-anchored comments on asset detail
- migration 010: asset_comments table (id, asset_id, user_id, body,
  frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
  PATCH/DELETE with author join (display_name + initials), nullable
  user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
  empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
  returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
  empty-state copy ('Add one below to mark a frame'), tooltips on
  the timestamp and resolved buttons

Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
2026-05-23 04:21:11 +00:00
claude
7da171cf1f polish: defensive hydration defaults on ZAMPP_DATA accessors
Guards against the brief window between app mount and the first
data load completing — empty arrays render gracefully instead
of throwing on .filter / .map.
2026-05-23 04:17:36 +00:00
claude
24820e921e polish: schedule past-time confirm, recorder name sanitization, asset detail player controls
- Schedule: if start_at is more than a minute in the past, confirm()
  before submitting (operator may want to fire immediately, but
  shouldn't do it accidentally)
- Recorders: generateClipName now sanitizes the recorder name so the
  S3 key / SMB path / ffmpeg arg stays clean — spaces become
  underscores, anything outside [A-Za-z0-9._-] is dropped, capped at 40
- Asset detail: audio mute + fullscreen buttons now key off streamUrl
  state (rather than videoRef.current which is null on first render)
  so they reliably appear when a stream is available
2026-05-23 04:12:42 +00:00
claude
47ad01d0b2 polish(projects,jobs,bins): row menus, real status bars, bulk retry
Projects:
- Per-row 3-dot menu in list view: Open / Rename / Delete (PATCH + DELETE)
- ProjectCard's bottom bar now shows real ready/in-flight/error counts
  for the project's assets instead of fake 70/20 segments
- After mutations, project list refreshes from /projects + recomputes
  asset counts client-side

Bins:
- GET /api/v1/bins now returns every bin across every project when
  no project_id is supplied; result rows include project_name + asset_count
- Asset right-click 'Move to bin' filters to bins in the same project as
  the asset and surfaces project_name as a tooltip

Jobs:
- 'Retry all failed' button in the header appears when there are
  failed jobs and POSTs /retry for each one in parallel
- Failed-row error message now clips with title= tooltip so 3KB
  ffmpeg stderr doesn't blow out the row layout

window.PROJECT_COLORS exposed for cross-screen access.
2026-05-23 04:09:13 +00:00
f474a77bcb feat(web-ui): style the asset right-click menu (.ctx-menu)
The AssetContextMenu in screens-library.jsx has shipped without
matching styles, so the menu rendered as raw HTML on the page. Adds
.ctx-menu / .ctx-header / .ctx-divider / .ctx-section-label / .ctx-empty
plus button + danger styles matching the existing .row-menu look.
2026-05-23 00:04:25 -04:00
claude
f186cdeacd polish(ui): wire dead buttons across asset detail, shell, containers, cluster
Asset detail:
- Download now fetches /assets/:id/hires presigned URL and triggers a
  named browser download instead of doing nothing
- More icon now opens a kebab menu (Copy ID, Delete permanently)
- Approve button removed (no backend); audio + fullscreen icons
  in the player controls now actually toggle mute / requestFullscreen

Shell:
- Sidebar Sign-out now POSTs /auth/logout + reloads (no-op when auth disabled, by design)
- Topbar Notifications bell removed (dead, no backend)
- Topbar search wired: typing + Enter routes to Library with the term
  pre-loaded into Library's own search box
- Cluster-healthy pip now polls /metrics/home every 30s so it reflects
  real online-vs-total instead of always showing green

Editor:
- Dead Export / Publish / Mark in / Mark out / Add to timeline / Step
  buttons are now visibly disabled with explanatory titles; a PREVIEW
  badge sits next to the sequence name so the WIP state is obvious

Containers / Cluster admin:
- Logs button opens a modal with the docker tail command + Copy button
  instead of a JS alert
- Restart now shows an inline toast (pending/ok/fail) instead of alerts
- Cluster Add Node / Drain / Logs replace alert() with a styled advice
  modal that supports multi-line commands + Copy
- Dead Cluster topology Graph/List tab toggle removed (only Graph is
  implemented anyway)
2026-05-23 04:04:08 +00:00
630dc75787 fix(web-ui): hide search wrapper (with dropdown) on narrow screens
Previously the responsive rule hid only .search, leaving the dropdown
positioned on its own wrapper. Target .search-wrap so input + results
both hide together.
2026-05-22 23:55:36 -04:00
899876c6cf feat(web-ui): style global search dropdown
Adds .search-wrap / .search-results / .search-result styles for the
new topbar command-palette dropdown. Per-kind pill colors distinguish
asset / project / recorder / job / user / nav results at a glance.
2026-05-22 23:55:14 -04:00
61d02d522b feat(web-ui): pass search-select handlers from App to Topbar
Wires onOpenAsset and onOpenProject through Topbar so that selecting
an asset/project from the global search opens the asset detail or
navigates to the project view. Adds openProjectFromAnywhere helper.
2026-05-22 23:53:19 -04:00
45c0e0f914 feat(web-ui): wire global search in topbar with results dropdown
Replaces the static topbar input with a working command-palette-style
search that queries ZAMPP_DATA across assets, projects, recorders,
jobs, users, and nav targets. Cmd/Ctrl+K focuses the input, arrow keys
move selection, Enter opens, Esc dismisses. Selecting an asset opens
the asset detail; project opens project view; other kinds navigate.
2026-05-22 23:52:49 -04:00
claude
992fbdfa20 fix(recorders,library): empty-capture handling + right-click context menu
Proxy failures ("moov atom not found"):
- root cause: failed/aborted SRT/RTMP recordings still uploaded 0-byte
  (or ftyp-only ~1KB) objects to S3, which ffmpeg can't probe
- worker proxy.js now bails on inputs < 4 KiB with a clear message
  before handing the file to ffmpeg
- capture-manager.stop() returns framesReceived + empty flag
- capture shutdown handler skips POST /assets entirely on empty
  sessions, instead calls new POST /assets/:id/mark-empty to flip
  the pre-created live asset to 'error' with a note

Library asset right-click menu:
- new AssetContextMenu component on screens-library.jsx; right-click
  any asset in grid or list view to open
- actions: Open, Rename, Move to bin (lists up to 10 bins), Remove
  from bin, Copy asset ID, Delete permanently (hard=true)
- viewport-aware positioning (won't clip past window edges)
- dismisses on outside click / contextmenu / scroll
- Library now refreshes via /assets after mutations; normalizeAsset
  exposed on window so the re-fetch shape matches boot
- ctx-menu styles in styles-rest.css
2026-05-23 03:52:30 +00:00
claude
9877ed351f fix(recorders): queue proxy on finalize + custom clip names
- POST /api/v1/assets: when transitioning from 'live' to 'processing'
  with a hi-res key but no proxy, queue a proxy job instead of just
  flipping status='ready'. Recorder-captured clips now get a proxy
  + thumbnail like upload-path assets do
- POST /api/v1/recorders/:id/start now accepts { clipName } in the body;
  operator-supplied name (sanitized to [A-Za-z0-9 ._-], capped at 80)
  overrides the auto-generated <recorder>_<timestamp> fallback
- RecorderRow gets a 'Clip name (optional)' input visible when stopped;
  Enter triggers Record, value sent on POST start, cleared on stop
- New POST /api/v1/assets/:id/generate-proxy and
  POST /api/v1/assets/backfill-proxies for one-shot cleanup of pre-fix
  clips that have a hi-res master but no proxy
2026-05-23 03:41:03 +00:00
claude
b128c9f5a9 fix(metrics): use real job_status enum values (queued/processing/complete) 2026-05-23 03:31:14 +00:00
claude
ef4c301149 feat(home,users): real metrics, working Users row actions + Groups CRUD
- Home: new /api/v1/metrics/home endpoint buckets last 24h of assets,
  jobs done/failed into hourly counts; sparklines now render real
  time-series instead of decorative sine waves
- Home stat cards are now clickable (route to relevant page) and the
  delta lines show real activity ("+N added in last 24h", "N completed")
- Home live-feed tiles use HlsPreview for recorders with a live_asset_id
- Users: row 3-dot menu is now a real popover with Rename / Reset
  password / Delete actions wired to PATCH /users/:id and DELETE
- Users: role is now an inline <select> that PATCHes immediately
- Users: Created column replaces fake 'last active' (no last_login
  tracking yet); group count is real
- Groups tab is now functional — list groups, create, expand to
  show + manage members (add/remove), delete; backed by existing
  /api/v1/groups CRUD
- Policies tab is now an honest 'coming soon' stub
- New icons: key, lock, edit; new .row-menu popover styles
2026-05-23 03:30:10 +00:00
claude
53196d38ce feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
  (pending / recording / completed / cancelled / failed), 10s
  auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
  recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
  (list/create/edit/cancel/delete), scheduler.js tick loop polling every
  15s; transitions trigger /recorders/:id/start and /stop via in-process
  HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
  completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-23 03:19:24 +00:00
claude
6398879b56 feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
  with explicit 'applied to every ingested file' wording, expose
  CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
  SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
  BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
  staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
  Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
  builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
  hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
  UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-23 02:58:32 +00:00
dc0bd51648 docs: growing-files + Premiere panel quickstart 2026-05-22 19:16:34 -04:00
c3991a1e75 docs: growing-files + Premiere panel quickstart 2026-05-22 19:16:04 -04:00
328f7b4f31 feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
3fc8fbc230 add per-seat/per-stream/per-month strikethrough hero to Tokens page 2026-05-22 17:29:23 -04:00
ceceedf201 probe fallback: basic TCP/UDP connectivity when capture service is offline 2026-05-22 17:26:26 -04:00
4864db03f3 probe: fallback to basic TCP/UDP connectivity check when capture service is offline 2026-05-22 17:22:30 -04:00
8b57a9a35a expand codec list, add MXF container, remove proxy settings (fixed profile) 2026-05-22 17:20:01 -04:00
fa787bbe1e fix hover-to-play: remove status filter so any asset triggers stream fetch 2026-05-22 17:18:56 -04:00
0aa0922fd3 remove hardcoded recorders badge 2026-05-22 17:18:20 -04:00
1abf22623d feat: hover-to-play video preview on library asset cards
Fetches stream URL on hover after 350ms delay; renders muted autoplay
video overlay over the thumbnail. Supports both mp4 and HLS streams.
Only triggers for ready/live assets to avoid pointless API calls.
2026-05-22 16:58:11 -04:00
4afd0c7b21 feat: add /cluster/containers endpoint via Docker socket
Lists all containers on the local host; supports POST /containers/:id/restart.
Falls back to [] gracefully if Docker socket is unavailable.
2026-05-22 16:57:33 -04:00
6f2de45819 feat: wire real video playback via GET /assets/:id/stream
- Fetch stream URL on asset open; show <video> element for mp4/hls
- Use hls.js for live HLS streams (loaded via CDN in index.html)
- Sync video play/pause/seek/timeupdate to React state
- Show loading state while fetching stream, status message when unavailable
- Add Retry processing button for error-status assets
- totalMs derived from video metadata when available, falls back to parseDuration
2026-05-22 13:37:55 -04:00
e3c3d60103 index: add hls.js for live stream HLS playback 2026-05-22 13:31:57 -04:00
81324c8e52 shell: add Field component (used by modal-new-recorder, was missing from global scope) 2026-05-22 12:56:33 -04:00
bec58ab138 screens-asset: fix thumbGrad crash, parseDuration NaN, guard missing ACTIVITY 2026-05-22 12:49:33 -04:00
451bed834f screens-admin: wire all buttons — invite user, export CSV, cluster refresh, container logs/restart, node drain/remove 2026-05-22 12:27:02 -04:00
d00e1c666e screens-ingest: wire delete button on RecorderRow 2026-05-22 12:24:10 -04:00
ddb4cf0c51 feat: add POST /jobs/:id/retry endpoint for re-queuing failed BullMQ jobs 2026-05-22 12:18:53 -04:00
fea0f2962b fix: wire Jobs Retry (POST /jobs/:id/retry) and Delete (DELETE /jobs/:id) buttons 2026-05-22 12:18:23 -04:00
506ee2d695 fix: wire New Project button — modal + POST /projects + state refresh 2026-05-22 12:17:54 -04:00
88689a4eb2 fix: wire Library Upload button to navigate to Upload screen 2026-05-22 12:17:29 -04:00
dc269bec00 fix: make Settings S3 form functional — load from API, save & test 2026-05-22 12:08:10 -04:00
665ab5238d feat: live status polling in RecorderRow, immediate refresh on mount 2026-05-22 11:35:13 -04:00
bb508d3256 feat: add probe button to SRT/RTMP sources, fix node labels 2026-05-22 11:33:45 -04:00
994fd799d0 fix: stop endpoint handles missing/dead containers gracefully 2026-05-22 11:32:44 -04:00
6510871448 fix: implement real upload (XHR + S3 multipart) and fix SDI recorder device_index + manual fallback: modal-new-recorder.jsx 2026-05-22 11:10:01 -04:00
26399f8d0a fix: implement real upload (XHR + S3 multipart) and fix SDI recorder device_index + manual fallback: screens-ingest.jsx 2026-05-22 11:10:00 -04:00
529d14cb6b fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: modal-new-recorder.jsx 2026-05-22 10:55:22 -04:00
fb44bd8aff fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: screens-editor.jsx 2026-05-22 10:55:20 -04:00
24a1d57165 fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: screens-ingest.jsx 2026-05-22 10:55:19 -04:00
48ee66e744 fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: screens-home.jsx 2026-05-22 10:55:18 -04:00
0342aa0a5a fix admin screen: move data destructuring inside components, normalize field names: screens-admin.jsx 2026-05-22 10:15:42 -04:00
406f28c663 feat(ui): wire ingest screens to real API (recorders, capture devices): screens-ingest.jsx 2026-05-22 10:07:13 -04:00
835545e061 feat(ui): wire library, jobs, ingest, editor screens to live API data: screens-editor.jsx 2026-05-22 10:05:57 -04:00
1392e28a88 feat(ui): wire library, jobs, ingest, editor screens to live API data: screens-jobs.jsx 2026-05-22 10:05:56 -04:00
bc03ee866b feat(ui): wire library, jobs, ingest, editor screens to live API data: screens-library.jsx 2026-05-22 10:05:54 -04:00
69f0d130ee feat(ui): wire screens to live API data; add thumbnail lazy-loading: screens-projects.jsx 2026-05-22 10:04:25 -04:00
07af51b05c feat(ui): wire screens to live API data; add thumbnail lazy-loading: screens-home.jsx 2026-05-22 10:04:24 -04:00
3574ae8a43 feat(ui): wire screens to live API data; add thumbnail lazy-loading: visuals.jsx 2026-05-22 10:04:23 -04:00
7dda7cc89c feat(ui): wire data.jsx to real API; add loading gate in app.jsx: app.jsx 2026-05-22 10:02:55 -04:00
98025001e8 feat(ui): wire data.jsx to real API; add loading gate in app.jsx: data.jsx 2026-05-22 10:02:54 -04:00
068e3a0828 fix(ui): replace FauxFrame SVG scenes with clean dark placeholder; strip fake LiveStrip animation: screens-editor.jsx 2026-05-22 09:31:58 -04:00
6ad277275b fix(ui): replace FauxFrame SVG scenes with clean dark placeholder; strip fake LiveStrip animation: visuals.jsx 2026-05-22 09:31:57 -04:00
f58fe95f0d fix(ui): remove placeholder elements — no scanlines, no DEV BUILD, no tweaks panel: screens-projects.jsx 2026-05-22 09:30:50 -04:00
6e763e8270 fix(ui): remove placeholder elements — no scanlines, no DEV BUILD, no tweaks panel: screens-home.jsx 2026-05-22 09:30:49 -04:00
6ac3050a05 fix(ui): remove placeholder elements — no scanlines, no DEV BUILD, no tweaks panel: index.html 2026-05-22 09:30:47 -04:00
e13d111b9f feat(ui): Dragonflight redesign — admin screens (users, tokens, containers, cluster, settings): screens-admin.jsx 2026-05-22 08:24:08 -04:00
1eaf9dff5c Add Z-AMPP UI: screens-ingest + screens-admin: screens-admin.jsx 2026-05-22 08:22:38 -04:00
20dfa504e5 Add Z-AMPP UI: screens-ingest + screens-admin: screens-ingest.jsx 2026-05-22 08:22:37 -04:00
575e350831 feat(ui): Dragonflight redesign — editor, admin, and new recorder modal: modal-new-recorder.jsx 2026-05-22 08:21:40 -04:00
7899066107 feat(ui): Dragonflight redesign — editor, admin, and new recorder modal: screens-editor.jsx 2026-05-22 08:21:39 -04:00
9289bd0c74 feat(ui): Dragonflight redesign — ingest, jobs, editor, admin screens: screens-jobs.jsx 2026-05-22 08:20:16 -04:00
0945f488f6 feat(ui): Dragonflight redesign — ingest, jobs, editor, admin screens: screens-ingest.jsx 2026-05-22 08:20:15 -04:00
bd9dfd2cce Add Z-AMPP UI: screens-jobs + screens-editor + modal-new-recorder: modal-new-recorder.jsx 2026-05-22 08:19:03 -04:00
b8e1796c33 Add Z-AMPP UI: screens-jobs + screens-editor + modal-new-recorder: screens-editor.jsx 2026-05-22 08:19:02 -04:00
f8bd80e38e Add Z-AMPP UI: screens-jobs + screens-editor + modal-new-recorder: screens-jobs.jsx 2026-05-22 08:19:01 -04:00
90d2c1cf82 feat(ui): Dragonflight redesign — screen components batch 2: screens-asset.jsx 2026-05-22 08:18:25 -04:00
55e82bdeb7 Add Z-AMPP UI: screens-asset + screens-projects: screens-projects.jsx 2026-05-22 08:17:18 -04:00
7007d2df93 Add Z-AMPP UI: screens-asset + screens-projects: screens-asset.jsx 2026-05-22 08:17:17 -04:00
ed3084e60f feat(ui): Dragonflight redesign — screen components batch 1: screens-projects.jsx 2026-05-22 08:17:06 -04:00
3392a8944d feat(ui): Dragonflight redesign — screen components batch 1: screens-library.jsx 2026-05-22 08:17:05 -04:00
c801eb4781 feat(ui): Dragonflight redesign — screen components batch 1: screens-home.jsx 2026-05-22 08:17:04 -04:00
4a77c1bed8 Add Z-AMPP UI: screens-home + screens-library: screens-library.jsx 2026-05-22 08:15:36 -04:00
100fc054cc Add Z-AMPP UI: screens-home + screens-library: screens-home.jsx 2026-05-22 08:15:35 -04:00
001533fdf0 feat(ui): Dragonflight redesign — visuals + tweaks panel: tweaks-panel.jsx 2026-05-22 08:15:35 -04:00
c0345e47c9 feat(ui): Dragonflight redesign — visuals + tweaks panel: visuals.jsx 2026-05-22 08:15:34 -04:00
dfdf0845a0 Add Z-AMPP UI: shell + app: app.jsx 2026-05-22 08:14:33 -04:00
f24912ea9e Add Z-AMPP UI: shell + app: shell.jsx 2026-05-22 08:14:32 -04:00
a9e0313fe4 Add Z-AMPP UI: visuals + tweaks-panel: tweaks-panel.jsx 2026-05-22 08:13:37 -04:00
d54d960b8f Add Z-AMPP UI: visuals + tweaks-panel: visuals.jsx 2026-05-22 08:13:36 -04:00
2706903353 feat(ui): Dragonflight redesign — foundation JSX files: app.jsx 2026-05-22 08:13:03 -04:00
b6dcecb672 feat(ui): Dragonflight redesign — foundation JSX files: shell.jsx 2026-05-22 08:13:02 -04:00
14bfcabcaf feat(ui): Dragonflight redesign — foundation JSX files: icons.jsx 2026-05-22 08:13:01 -04:00
3b1610a167 feat(ui): Dragonflight redesign — foundation JSX files: data.jsx 2026-05-22 08:13:00 -04:00
d5fd705d66 feat(web-ui): Z-AMPP core JSX files (data, icons, visuals, tweaks, shell, app): icons.jsx 2026-05-22 08:09:04 -04:00
a700124f50 feat(web-ui): Z-AMPP core JSX files (data, icons, visuals, tweaks, shell, app): data.jsx 2026-05-22 08:09:03 -04:00
10952df591 feat(web-ui): add styles-asset and styles-rest CSS: styles-rest.css 2026-05-22 08:07:16 -04:00
afd3fd3374 feat(web-ui): add styles-asset and styles-rest CSS: styles-asset.css 2026-05-22 08:07:15 -04:00
352d21496f feat(web-ui): asset detail + rest CSS: styles-rest.css 2026-05-22 08:06:40 -04:00
016adff949 feat(web-ui): asset detail + rest CSS: styles-asset.css 2026-05-22 08:06:39 -04:00
fcd5a9aa09 feat(web-ui): add remaining design CSS files: styles-modal.css 2026-05-22 08:04:35 -04:00
304d3713b7 feat(web-ui): add remaining design CSS files: styles-screens.css 2026-05-22 08:04:33 -04:00
6befb0f46a feat(web-ui): Z-AMPP screen + component CSS: styles-modal.css 2026-05-22 08:03:57 -04:00
e655ccdf64 feat(web-ui): Z-AMPP screen + component CSS: styles-screens.css 2026-05-22 08:03:55 -04:00
2c88fb0a03 feat(web-ui): Z-AMPP design system CSS: styles-fixes.css 2026-05-22 08:02:36 -04:00
7b13d8bd0f feat(web-ui): Z-AMPP design system CSS: styles.css 2026-05-22 08:02:35 -04:00
21db567396 feat(web-ui): new design system CSS from Claude Design: styles-fixes.css 2026-05-22 08:02:08 -04:00
68df3797f1 feat(web-ui): new design system CSS from Claude Design: styles.css 2026-05-22 08:02:07 -04:00
dccca554e0 Add Dragonflight React SPA design - index.html and CSS: styles-fixes.css 2026-05-21 23:53:21 -04:00
1b63429def Add Dragonflight React SPA design - index.html and CSS: index.html 2026-05-21 23:53:19 -04:00
87da3c0b58 feat: migrate cluster.html to wd-* design system 2026-05-21 23:17:48 -04:00
06551c66a6 feat: migrate editor.html to wd-* design system 2026-05-21 23:16:46 -04:00
136820c8f9 feat: migrate capture.html to wd-* design system 2026-05-21 23:16:29 -04:00
7c88692c1c feat: migrate recorders.html to wd-* design system 2026-05-22 03:16:27 +00:00
1e0015322c feat: migrate projects.html to wd-* design system 2026-05-21 23:15:57 -04:00
6176791174 feat: migrate player.html to wd-* design system 2026-05-21 23:15:18 -04:00
9ff80f8cc1 feat: migrate upload.html to wd-* design system 2026-05-21 23:14:51 -04:00
738d6298d2 feat: migrate edit.html to wd-* design system 2026-05-21 23:14:19 -04:00
a84bc3ecfe feat: migrate api-tokens.html to wd-* design system 2026-05-21 23:14:09 -04:00
daa203a43e feat: migrate index.html to wd-* design system 2026-05-21 23:13:13 -04:00
33d2a4004d feat: migrate jobs.html to wd-* design system 2026-05-21 23:12:58 -04:00
6e43ab30c2 rebrand: Recorders — Dragonflight, ember orange hue-32 2026-05-22 02:54:10 +00:00
cc45cc6347 rebrand: _primitives-smoke — Dragonflight brand 2026-05-21 22:52:12 -04:00
c31933a53c rebrand: Projects — Dragonflight, ember orange hue-32 2026-05-21 22:51:20 -04:00
efe005378a rebrand: Editor (in development) — Dragonflight, ember orange hue-32 2026-05-21 22:49:43 -04:00
5874c93956 rebrand: Editor — Dragonflight, ember orange hue-32 2026-05-21 22:49:00 -04:00
cd5fc3a05c rebrand: upload.html — Dragonflight title + sidebar 2026-05-21 22:44:25 -04:00
e0a2d0c95c rebrand: jobs.html — Dragonflight title + sidebar 2026-05-21 22:42:56 -04:00
4572c88f58 rebrand: capture.html — Dragonflight title + sidebar + hue-32 2026-05-21 22:40:48 -04:00
c752227e20 rebrand: api-tokens.html — Dragonflight title + sidebar 2026-05-21 22:39:20 -04:00
d4e5af459e rebrand: settings.html — Z-AMPP → Dragonflight 2026-05-21 22:35:33 -04:00
29360e38e8 rebrand: users.html — Z-AMPP → Dragonflight 2026-05-21 22:33:21 -04:00
e5f4c00729 rebrand: containers.html — Z-AMPP → Dragonflight 2026-05-21 22:31:41 -04:00
c6aeedb5fc rebrand: Dragonflight — cluster.html brand names 2026-05-21 22:27:36 -04:00
32cf6bf63e rebrand: Dragonflight — tokens.html brand names and footer text 2026-05-21 22:26:24 -04:00
024833cc95 rebrand: Dragonflight — login.html brand names, description text 2026-05-21 22:22:58 -04:00
b4642b3c78 rebrand: Dragonflight — index.html brand names, splash screen, accent colors 2026-05-21 22:22:12 -04:00
b82cc73cf1 rebrand: Dragonflight — home.html wordmark, accent gradients, brand names 2026-05-21 22:19:00 -04:00
873920d27f rebrand: Dragonflight — ember orange accent (hue 266→32) 2026-05-21 22:16:32 -04:00
37767f9939 fix(cluster): pickIp() only treats 172.17.x as docker bridge, not all of RFC1918 172.16/12 2026-05-21 21:27:15 -04:00
00f3f2905f feat(cluster): expose db/redis ports for worker-node connectivity 2026-05-21 21:23:23 -04:00
30b4deffc6 fix(capture): proper SDK 16 patch via upstream FFmpeg master diff
The previous patch_decklink.py mixed v14_2_1 versioned types (Fix 1 renamed the allocator class) with no-ops for SetVideoInputFrameMemoryAllocator + QueryInterface-around-GetBytes (Fixes 2 & 3). That inconsistency compiled but silently dropped every video frame: VideoInputFrameArrived saw _v14_2_1 allocator output but tried to read it via the SDK 16 unversioned IDeckLinkVideoBuffer path, and the SDK released the buffer before FFmpeg could consume it.

Bisected with the BMD-provided Capture sample at SDK 16 mode 5 (Hp29) which got frames cleanly, confirming the signal was fine and the bug was in FFmpegs decklink demuxer.

Fix: pull libavdevice/decklink_{enc,dec,common}{.cpp,.h} from upstream FFmpeg master (commits past 7.1 that fully rename every decklink interface to its _v14_2_1 versioned form) and apply that diff in reverse during build. Now build is internally consistent and frames flow.

Verified: SDI1 recorder on zampp2 hits 423 frames in 14s @ 29 fps, ProRes HQ at 91 Mbps.
2026-05-22 00:53:03 +00:00
96f0f58e68 capture: yadif deint=1 so progressive SDI passes through unchanged
Bug: yadif=mode=1 unconditionally doubled output framerate for SDI input. On 1080p29.97 progressive sources the encoder produced zero frames (time advanced, size stayed at 1KiB MOV header).

Fix: deint=1 makes yadif only process frames flagged as interlaced; progressive frames pass through at the source rate.
2026-05-22 00:14:02 +00:00
a8656fc1a8 capture: custom FFmpeg 7.1 build with DeckLink + D-Bus mounts + SDI deinterlace
Dockerfile is now a two-stage build that compiles FFmpeg from source with --enable-decklink against the Blackmagic SDK 16.x headers in services/capture/sdk/ (operator-supplied, gitignored). build-with-decklink.sh + patch_decklink.py drive the build.

docker-compose.yml mounts /dev/shm, /run/dbus, /run/systemd into mam-api, capture, web-ui so the BMD runtime can talk to the host.

capture-manager.js wraps SDI sources with -vf yadif=mode=1 (deinterlace).

recorders.html defaults to SDI source type now that we have a working DeckLink path.
2026-05-22 00:01:43 +00:00
1074104d34 fix(capture): FFmpeg 7.x DeckLink compatibility 2026-05-22 00:00:02 +00:00
486e3c27e4 fix(decklink): mount /dev/blackmagic in sidecar + remote node routing via node-agent
Two bugs fixed:
1. SDI capture sidecar never had /dev/blackmagic bound — ffmpeg opened the
   decklink input inside a container with no device nodes, so frame=0.
   Fix: local spawns now push '/dev/blackmagic:/dev/blackmagic' onto Binds
   when source_type='sdi'.

2. recorders.js always spawned sidecars against the local Docker socket
   (zampp1), even when a recorder's node_id pointed at zampp2 (where the
   card is). Fix: resolveNodeTarget() looks up the recorder's cluster node;
   if it's a different hostname the sidecar is spawned via a new
   POST /sidecar/start endpoint on the remote node-agent.

node-agent gains three new routes (all talk to the local Docker socket):
  POST   /sidecar/start         — create + start container (host network,
                                   privileged, /dev/blackmagic bind for sdi)
  DELETE /sidecar/:id           — stop + remove
  GET    /sidecar/:id/status    — inspect + poll capture service

docker-compose.worker.yml: add /var/run/docker.sock and LIVE_DIR to
node-agent so it can spawn sidecars, and document build-capture prerequisite.: docker-compose.worker.yml
2026-05-21 18:51:11 -04:00
bbed2a7059 fix(decklink): mount /dev/blackmagic in sidecar + remote node routing via node-agent
Two bugs fixed:
1. SDI capture sidecar never had /dev/blackmagic bound — ffmpeg opened the
   decklink input inside a container with no device nodes, so frame=0.
   Fix: local spawns now push '/dev/blackmagic:/dev/blackmagic' onto Binds
   when source_type='sdi'.

2. recorders.js always spawned sidecars against the local Docker socket
   (zampp1), even when a recorder's node_id pointed at zampp2 (where the
   card is). Fix: resolveNodeTarget() looks up the recorder's cluster node;
   if it's a different hostname the sidecar is spawned via a new
   POST /sidecar/start endpoint on the remote node-agent.

node-agent gains three new routes (all talk to the local Docker socket):
  POST   /sidecar/start         — create + start container (host network,
                                   privileged, /dev/blackmagic bind for sdi)
  DELETE /sidecar/:id           — stop + remove
  GET    /sidecar/:id/status    — inspect + poll capture service

docker-compose.worker.yml: add /var/run/docker.sock and LIVE_DIR to
node-agent so it can spawn sidecars, and document build-capture prerequisite.: recorders.js
2026-05-21 18:51:10 -04:00
8186b181cc fix(decklink): mount /dev/blackmagic in sidecar + remote node routing via node-agent
Two bugs fixed:
1. SDI capture sidecar never had /dev/blackmagic bound — ffmpeg opened the
   decklink input inside a container with no device nodes, so frame=0.
   Fix: local spawns now push '/dev/blackmagic:/dev/blackmagic' onto Binds
   when source_type='sdi'.

2. recorders.js always spawned sidecars against the local Docker socket
   (zampp1), even when a recorder's node_id pointed at zampp2 (where the
   card is). Fix: resolveNodeTarget() looks up the recorder's cluster node;
   if it's a different hostname the sidecar is spawned via a new
   POST /sidecar/start endpoint on the remote node-agent.

node-agent gains three new routes (all talk to the local Docker socket):
  POST   /sidecar/start         — create + start container (host network,
                                   privileged, /dev/blackmagic bind for sdi)
  DELETE /sidecar/:id           — stop + remove
  GET    /sidecar/:id/status    — inspect + poll capture service

docker-compose.worker.yml: add /var/run/docker.sock and LIVE_DIR to
node-agent so it can spawn sidecars, and document build-capture prerequisite.: index.js
2026-05-21 18:51:09 -04:00
539429c058 tokens.html: remove orphan sidebar-footer + duplicate /nav; use main flex wrapper 2026-05-21 16:40:28 -04:00
01a9d6c3db settings.html: remove orphan sidebar-footer + duplicate /nav; use main flex wrapper 2026-05-21 16:40:28 -04:00
Zac
ddd3b3eca1 Revert shell.css primitive rework (5 commits eea1ed6..a8f5bce) 2026-05-21 20:34:08 +00:00
a8f5bce9ee home.html: drop per-page body+shell rules (now in shell primitive) 2026-05-21 16:22:49 -04:00
683f0ff101 containers.html: drop inline shell hack (now in shell primitive) 2026-05-21 16:22:49 -04:00
47c0e1f933 users.html: drop inline shell hack (now in shell primitive) 2026-05-21 16:22:48 -04:00
6cad11f687 app.css: import new shell primitive; drop redundant html base rule (now in shell.css) 2026-05-21 16:22:47 -04:00
eea1ed6bcb shell.css: codify body + .wd-shell + .wd-main as a primitive (fix dead-space layout bug) 2026-05-21 16:22:46 -04:00
c6bcbbd214 web-ui(wave 2): migrate settings.html to new primitives
Surgical migration: stylesheet swap to /dist/app.css + sidebar markup
updated to wd-sidebar primitives. Page-specific content + JS unchanged.
2026-05-21 13:35:04 -04:00
e7495dfe29 web-ui(wave 2): migrate tokens.html to new primitives
Surgical migration: stylesheet swap to /dist/app.css + sidebar markup
updated to wd-sidebar primitives. Page-specific content + JS unchanged.
2026-05-21 13:35:03 -04:00
5650b279c3 web-ui(wave 2): migrate users.html to new primitives 2026-05-21 13:33:22 -04:00
596fe228ed web-ui(wave 2): migrate containers.html to new primitives 2026-05-21 13:33:22 -04:00
e0cfe80a9e web-ui(wave 2): migrate home.html to new primitives
Swap stylesheet to /dist/app.css. Sidebar markup ported to wd-sidebar /
wd-nav-item / wd-sidebar-* primitives (active state = leading accent dot).
Logout button promoted to wd-btn--ghost--sm--icon.

Hero (portrait, wordmark, tagline) and the 10 illustrated cards keep
their bespoke design — they're a brand moment. Hardcoded oklch values
in the inline style replaced with var(--accent-bright), var(--signal-bad),
var(--bg-base), var(--accent-border), var(--overlay), etc. wherever the
brand palette already provides them.

All JS ids preserved (assetCount, projectCount, recorderCount,
containerCount, nodeCount, jobCount, ingestCount, captureStatus,
tokenBurn, userAvatar, userName, userRole, logoutBtn, systemBuild).
loadStats() poll cycle unchanged.
2026-05-21 13:19:16 -04:00
16a34a2fad web-ui(wave 2): migrate login.html to new primitives
Keeps the hero + AMPP Safe stamp + brand panel. Right column now uses
wd-form-group / wd-input / wd-label / wd-btn primitives loaded from
/dist/app.css. All JS ids and handlers preserved verbatim (login-form,
setup-form, flash, show-setup, show-login).

Visual changes: tighter form spacing, focus rings use accent-subtle
ring, flash messages use the top-strip toast pattern.
2026-05-21 13:09:39 -04:00
75b94a5025 web-ui(wave 2): token cleanups from wave-1 code review
Promoted 14 new tokens (--accent-hover, --signal-{good,bad,warn}-hover,
--accent-bright, --thumb-black, --overlay, --shadow, --ease-out-{quart,expo},
--dur-{fast,normal,slide}, --z-topbar) and substituted every raw oklch /
cubic-bezier / hardcoded z-index occurrence in the 12 primitive files.

cubic-bezier appearances dropped from 8 files to 0 (only in tokens.css).
Bundle byte count: 138 KB -> 139 KB. Visual regression: zero (smoke page
still renders identically).
2026-05-21 17:08:02 +00:00
265f4174d5 docs: UI shell rework wave-2 implementation plan
7 tasks: token cleanups from wave-1 review (task 0), then migrate login/home/settings/tokens/users/containers.html one-by-one with deploy-and-verify between each. Smallest blast radius first.
2026-05-21 13:06:04 -04:00
447b2b2b81 web-ui: add _primitives-smoke.html for wave-1 visual QA
Delete at end of wave 4 once every shell page has migrated to the new primitives.
2026-05-21 12:43:00 -04:00
3b89cf2d5f web-ui: fix wave-1 build pipeline (primitives missing from bundle)
Three bugs found during task 20 verify, all fixed:

1. Tailwind CLI does NOT read postcss.config.js. Switched Dockerfile to
   npx postcss + postcss-cli so the postcss plugin chain actually runs.

2. postcss-import was not installed but app.css uses @import for the
   primitive component files. Added postcss-import + cssnano (for prod
   minification under --env production).

3. @import statements must come BEFORE any other rules per CSS spec.
   app.css had @tailwind base/components ABOVE @import, so postcss-import
   silently skipped every component @import. Moved all @imports to the
   top, @tailwind directives below. Bundle went from 121KB with 0 wd-*
   classes to 138KB with 116 wd-* classes.

Also added tailwind safelist for wd-/is-/nav-dev-badge so the wave-2
migration of HTML files cannot accidentally tree-shake primitives.
2026-05-21 16:41:55 +00:00
f9236101b9 web-ui: wave-1 finish — self-host fonts + multi-stage Dockerfile
Fonts: Inter 400/500/600 + JetBrains Mono 400/600 (woff2 from rsms/inter and JetBrains/JetBrainsMono github).

Dockerfile: two-stage build. Stage 1 (node:20-alpine) runs tailwindcss --minify to emit public/dist/app.css. Stage 2 (nginx:alpine) ships the static result.

NOTE on task 19 (nginx caching for /dist /fonts): SKIPPED. The existing nginx.conf already caches *.css and *.woff2 for 1y immutable via the generic location ~* \\.(css|...|woff2|...)$ regex. Adding explicit /dist/ and /fonts/ blocks would be redundant.
2026-05-21 16:32:55 +00:00
6561cecf33 web-ui: fix corrupted .gitignore from earlier patch 2026-05-21 12:31:18 -04:00
a4bb6e7b0c web-ui: add node_modules + public/dist to .gitignore for wave-1 build 2026-05-21 12:30:56 -04:00
1f995c9029 web-ui: wave-1 foundation — services/web-ui/tailwind.config.js 2026-05-21 12:30:41 -04:00
891a8f82b7 web-ui: wave-1 foundation — services/web-ui/src/css/components/topbar.css 2026-05-21 12:30:40 -04:00
23ae848f5b web-ui: wave-1 foundation — services/web-ui/src/css/components/tokens.css 2026-05-21 12:30:40 -04:00
a16c235f71 web-ui: wave-1 foundation — services/web-ui/src/css/components/toast.css 2026-05-21 12:30:39 -04:00
e56704b69f web-ui: wave-1 foundation — services/web-ui/src/css/components/slide-panel.css 2026-05-21 12:30:39 -04:00
1c0ed05ac9 web-ui: wave-1 foundation — services/web-ui/src/css/components/sidebar.css 2026-05-21 12:30:38 -04:00
a6c9f88068 web-ui: wave-1 foundation — services/web-ui/src/css/components/motion.css 2026-05-21 12:30:38 -04:00
310eca0810 web-ui: wave-1 foundation — services/web-ui/src/css/components/list-row.css 2026-05-21 12:30:38 -04:00
a76e6b9a81 web-ui: wave-1 foundation — services/web-ui/src/css/components/form-controls.css 2026-05-21 12:30:37 -04:00
836a163cc8 web-ui: wave-1 foundation — services/web-ui/src/css/components/field-group.css 2026-05-21 12:30:37 -04:00
052a880b0f web-ui: wave-1 foundation — services/web-ui/src/css/components/empty-state.css 2026-05-21 12:30:36 -04:00
2f3e04cfc3 web-ui: wave-1 foundation — services/web-ui/src/css/components/card-operational.css 2026-05-21 12:30:36 -04:00
080f82e198 web-ui: wave-1 foundation — services/web-ui/src/css/components/card-asset.css 2026-05-21 12:30:36 -04:00
c08025eeb2 web-ui: wave-1 foundation — services/web-ui/src/css/components/button.css 2026-05-21 12:30:35 -04:00
30cb6663dd web-ui: wave-1 foundation — services/web-ui/src/css/components/badge.css 2026-05-21 12:30:35 -04:00
e256a771d5 web-ui: wave-1 foundation — services/web-ui/src/css/app.css 2026-05-21 12:30:34 -04:00
3df6a4434e web-ui: wave-1 foundation — services/web-ui/postcss.config.js 2026-05-21 12:30:34 -04:00
9d99811272 web-ui: wave-1 foundation — services/web-ui/package.json 2026-05-21 12:30:33 -04:00
c97759dc4e docs: UI shell rework wave-1 implementation plan
Detailed 22-task plan for the foundation wave (build pipeline + theme
port + every CSS primitive, no page migration yet). Smoke-test page at
_primitives-smoke.html is the wave-1 QA gate. Waves 2-4 will get their
own plan documents after each prior wave ships.
2026-05-21 10:53:31 -04:00
b77a370eb7 docs: clarify responsive viewport tiers in UI rework spec
Self-review caught an ordering ambiguity in the responsive section: 1280x800
is the fully-supported minimum, tablet 768-1279 is best-effort. Rewording
so the tiers list top-down by viewport size.
2026-05-21 10:45:59 -04:00
b36e859c06 docs: UI shell rework design spec (2026-05-21)
Full design spec for the flyon-ui-based shell rework. All 7 design
sections (build system, sidebar, topbar, cards, forms/slide-panel,
states/motion, a11y/responsive/rollout) approved by user during
brainstorming. Next step is the implementation plan via writing-plans.
2026-05-21 10:45:08 -04:00
fd955076dd web-ui: fix codec/settings panel clipping in recorders.html
Flex-child overflow footgun: .slide-panel-body had flex:1 and overflow-y:auto
but without min-height:0 it never shrank below content height, so the new
Master/Proxy codec blocks overflowed past the panel bottom and the footer
(Cancel / Probe / Save buttons) was unreachable. Lock the panel to 100vh,
add min-height:0 to the body. Also drop redundant margin-bottom on
.codec-block since the body already has gap spacing.
2026-05-21 14:10:24 +00:00
89ceef444e web-ui: include auth-guard.js on home.html and projects.html so the IN DEV badge renders on those pages too 2026-05-21 14:01:52 +00:00
00bf112b5a web-ui: replace editor.html with under-construction screen
The timeline editor isn't ready yet. Replace the 49 KB prototype page
with a clean construction screen (still rendering the standard sidebar
so users can navigate away). The 'IN DEV' badge on the sidebar nav item
is injected by auth-guard.js across all pages.
2026-05-21 09:59:29 -04:00
16a1fe604f web-ui: tag IN DEV pages in sidebar from auth-guard
Adds a tiny CSS rule + DOM patch that walks .nav-item links on every
page and appends an 'IN DEV' badge to those matching a known in-dev
page (currently just editor.html). Avoids touching all 13 HTML files
for the same single-line nav change.
2026-05-21 09:59:29 -04:00
f6c0594088 web-ui: rewrite recorders.html with tabbed codec settings + BMD card picker
- Replace flat codec dropdowns with Master/Proxy blocks, each with
  Video/Audio/Container tabs.
- Replace BM1/BM2 device dropdown with cluster-node picker plus
  inline BMDCards.render(...) SVG -- click a port to set device_index.
- Wire full codec field set (video bitrate, framerate, audio codec/
  bitrate/channels, container) end-to-end to /api/v1/recorders.
- Auto-hide bitrate input for profile-driven codecs (ProRes, DNxHR,
  PCM, FLAC); show for H.264/265/NVENC, AAC, AC-3, Opus, DNxHD.
- Resolve SDI source display in cards via /cluster/devices/blackmagic
  (hostname + model + port) instead of raw device index.

Finishes the pending item from
docs/superpowers/plans/2026-05-21-cluster-codec-revamp.md.
2026-05-21 09:47:32 -04:00
8403355ba9 docs: add handoff plan for cluster + codec revamp session
Captures the full commit list, current cluster state, the 172.18 LAN topology gotcha, the remaining recorders.html UI rewrite, and how the next agent should pick it up.
2026-05-21 13:13:42 +00:00
4a3a672cbe cluster: stable hostname for mam-api, jq-based smoke test
mam-api self-heartbeat now reads NODE_HOSTNAME so primary rows survive container restarts instead of resurrecting with the random container ID. test-cluster.sh rewritten to use jq (the python f-strings had a parse bug that silently passed the IP check) and limited the docker-bridge alarm to 172.17.x since the user LAN occupies 172.18.0.0/16.
2026-05-21 11:50:52 +00:00
8aa378348e deploy: add cluster smoke-test script; remove rate-limit probe
test-cluster.sh validates primary /health, dedup hostnames, real LAN IPs, per-node /health, NVENC encode, and Blackmagic enumeration.
2026-05-21 11:38:30 +00:00
97628bb67d chore: remove cloudflare rate-limit probe 2026-05-21 07:34:47 -04:00
46676bf80d test: rate-limit probe 2026-05-21 00:32:09 -04:00
0ebb3cffe4 onboard-node: auto-detect host LAN IP and pass NODE_IP + BMD_MODEL
Resolves the host's primary LAN address with `ip route get` so the
node-agent reports it in the heartbeat instead of a docker bridge IP.
Adds BMD_MODEL forwarding so the recorder UI knows which card to draw,
and reads back the agent's /health response to confirm the resolved IP.
2026-05-21 00:20:46 -04:00
d39f86d9c5 ui: add bmd-card.js — visual DeckLink port picker
Renders an inline SVG of the detected card (Duo 2 / Quad 2 / Mini
Recorder / Mini Monitor / UltraStudio 4K Mini, with a generic fallback)
showing each connector in its real physical position. Click to select.
Used by recorders.html for SDI source selection.
2026-05-21 00:19:51 -04:00
f4a83eedc4 capture-manager: dynamic ffmpeg args from per-recorder codec config
Adds a VIDEO_CODECS / AUDIO_CODECS / CONTAINER catalogue and a
buildEncodeArgs() that composes -c:v / -c:a / -b:v / -b:a / -r / -ac / -f
from the recorder's saved settings. Master and proxy each get their own
codec stack, both honour the container format chosen in the UI, and the
S3 keys now use the actual container extension instead of hardcoded mov/mp4.
2026-05-21 00:19:00 -04:00
4c65753358 recorders route: accept full codec field set + node/port pinning
POST/PATCH now persist all new codec columns via a whitelist. /start
forwards every codec setting to the capture container as an env var. The
live-asset created during /start now uses the recorder's container ext
(.mov vs .mp4 etc.) instead of always assuming .mov.
2026-05-21 00:17:45 -04:00
0efef0d81b cluster route: fallback IP from request + /devices/blackmagic endpoint
Heartbeat handler now overrides 172.x docker bridge IPs with the
request's source address when the request itself came from a real LAN.
Adds GET /devices/blackmagic that flattens every node's capabilities so
the recorder UI can show a card picker spanning the whole cluster.
2026-05-21 00:16:36 -04:00
485af25d4a capture bootstrap: forward every codec env var to captureManager.start 2026-05-21 00:16:03 -04:00
3b4af6ef11 node-agent: prefer NODE_IP and skip docker bridge interfaces
In bridge mode the agent was reporting the container's 172.x address
because the first non-internal interface in os.networkInterfaces() was
docker0. Now honours NODE_IP, skips lo/docker*/br-*/veth*/etc, and
down-ranks the 172.16-31 range so real LAN IPs win. Also exposes the
detected IP on /health for the onboarding script to print.
2026-05-21 00:15:03 -04:00
40a66bae03 worker compose: run node-agent in host network mode
The agent now uses network_mode: host so os.hostname() and the network
interface list reflect the actual host instead of a per-container random
hex hostname (root cause of duplicate Zampp2 rows) and a 172.x docker
bridge IP. Also passes NODE_IP and BMD_MODEL through for explicit overrides.
2026-05-21 00:14:33 -04:00
049beb8818 recorders: add granular codec / container / port columns
Expands recorders with video bitrate, framerate, audio codec / bitrate
/ channels, container format, and a node_id/device_index pair so the UI
can pin SDI recorders to a specific node + DeckLink port instead of
relying on a flat "BM1/BM2" index. capture-manager.js consumes these via
env vars and builds ffmpeg args from them.
2026-05-21 00:14:11 -04:00
a39c9831c5 cluster: dedupe rows + enforce unique hostname index
Migration 004 wrapped table creation in IF NOT EXISTS, so deploys with
a pre-existing cluster_nodes table never picked up the inline UNIQUE
constraint and accumulated duplicate hostnames on every container
restart. This migration purges older duplicates and adds the unique
index idempotently so the ON CONFLICT (hostname) upsert finally works.
2026-05-21 00:14:01 -04:00
066b9b17d3 feat: expand GPU transcoding settings (extension, framerate, rc mode, audio) 2026-05-20 23:41:42 -04:00
629022ab5f fix(worker): use npm install instead of npm ci — no package-lock.json present 2026-05-20 23:29:15 -04:00
cc8ee63639 fix(node-agent): replace express with built-in http — no external deps needed 2026-05-20 22:59:03 -04:00
21d31f1678 mam-api: switch base image to node:22-slim (glibc) so host nvidia-smi binary runs 2026-05-20 19:19:33 -04:00
4d101bc812 mam-api: mount host nvidia-smi into container for GPU detection 2026-05-20 19:18:03 -04:00
1e4e2e436f mam-api: add NODE_IP env passthrough and NVIDIA GPU device reservation 2026-05-20 18:59:55 -04:00
74299629e6 feat: detect GPUs via nvidia-smi and populate cluster_nodes capabilities 2026-05-20 17:25:11 -04:00
a4b9b5be82 fix: prefer NODE_IP env var in getLocalIp() for Docker deployments 2026-05-20 16:16:09 -04:00
a926da1c30 feat: add settings key-value table migration 2026-05-20 15:57:23 -04:00
11e1de1cf8 feat: add S3 / Object Storage settings section 2026-05-20 15:55:34 -04:00
7032cee6b3 feat: call loadS3ConfigFromDb() on startup after migrations 2026-05-20 15:53:26 -04:00
02cfa68b92 fix(assets): replace static S3_BUCKET with getS3Bucket() for dynamic config 2026-05-20 15:49:40 -04:00
737e69d72f fix(upload): replace static S3_BUCKET with getS3Bucket() for dynamic config 2026-05-20 15:48:48 -04:00
ab504841c3 feat(settings): add S3 / object-storage settings routes (GET, PUT, test) 2026-05-20 15:48:14 -04:00
b1457f0aad feat(s3): dynamic DB-driven config with rebuildS3Client + Proxy export 2026-05-20 15:47:40 -04:00
beb58d3cd6 Add Settings nav link to sidebar 2026-05-20 15:07:36 -04:00
2f48d0243b Add Settings nav link to sidebar 2026-05-20 15:06:41 -04:00
cfdd0d1a55 Add Settings nav link to sidebar 2026-05-20 15:05:16 -04:00
0318d15c76 Add Settings nav link to sidebar 2026-05-20 15:02:09 -04:00
0433fc8805 fix(home): prevent bottom cutoff — safe center + remove min-height: 100% 2026-05-20 15:01:50 -04:00
777fa7fc2b Add Settings nav link to sidebar 2026-05-20 14:56:04 -04:00
53392608e5 Add Settings nav link to sidebar 2026-05-20 14:51:37 -04:00
b7c7bb1662 Add Settings nav link to sidebar 2026-05-20 14:50:02 -04:00
81d51577b8 Add Settings nav link to sidebar 2026-05-20 14:47:54 -04:00
dd1c40c9c8 Add Settings nav link to sidebar 2026-05-20 14:45:49 -04:00
7c37eababd Add Settings nav link to sidebar 2026-05-20 14:42:46 -04:00
6d371beda9 Add Settings nav link to sidebar 2026-05-20 14:39:36 -04:00
53805f2c59 Add Settings nav link to sidebar 2026-05-20 14:37:38 -04:00
74e87359e2 Add Settings nav link to sidebar 2026-05-20 14:36:03 -04:00
5e2683aba7 Add Settings nav link to sidebar 2026-05-20 14:32:34 -04:00
fe921d0444 Add Settings nav link to sidebar 2026-05-20 14:29:41 -04:00
12a52c40c9 feat: settings.html — GPU transcoding, SDI capture routing, AMPP integration 2026-05-20 14:21:18 -04:00
7486539b32 feat: worker compose adds capture profile (BMD DeckLink) and GPU env vars 2026-05-20 14:19:21 -04:00
a55c182be9 feat: docker-compose.gpu.yml overlay — NVIDIA GPU pass-through + NVENC worker 2026-05-20 14:19:02 -04:00
76281b7564 feat: GPU worker Dockerfile using CUDA base with ffmpeg NVENC support 2026-05-20 14:18:55 -04:00
1725ec1de9 feat: settings routes for hardware inventory, GPU transcoding, capture service URL 2026-05-20 14:18:43 -04:00
dd3c2894f6 feat: cluster heartbeat stores capabilities (GPU/BMD hardware detection) 2026-05-20 14:18:22 -04:00
a941f609f0 feat: node-agent detects NVIDIA GPUs and Blackmagic DeckLink cards, reports in heartbeat 2026-05-20 14:18:07 -04:00
86d2960b60 feat: add capabilities column to cluster_nodes (migration 005) 2026-05-20 14:17:44 -04:00
28a97e2ba3 fix(deploy): test-api.sh — skip capture 404 (sidecar/idle mode is normal) 2026-05-20 13:56:26 -04:00
5161644205 fix(capture): handle non-JSON responses from capture service gracefully 2026-05-20 13:55:06 -04:00
96ef569720 fix(deploy): test-api.sh — fix login POST, sequences 400, settings/ampp path 2026-05-20 13:54:30 -04:00
115c7340ee fix(deploy): test-api.sh — fix curl -f flag swallowing 4xx, fgrep for literal [, correct /auth/me path 2026-05-20 13:52:59 -04:00
4dd377e28d feat(cluster): add GET /:id/ping to probe node agent reachability and latency 2026-05-20 13:49:56 -04:00
3128ab43b3 feat(deploy): test-api.sh — API smoke test covering all major endpoints 2026-05-20 13:49:35 -04:00
0b49f28a80 feat(deploy): onboard-node.sh — one-command cluster node provisioning 2026-05-20 13:49:05 -04:00
0b255e063f feat(deploy): docker-compose.worker.yml for cluster worker nodes 2026-05-20 13:48:27 -04:00
c5a358888b feat(node-agent): heartbeat agent — CPU/mem stats, health endpoint, bearer token auth 2026-05-20 13:48:18 -04:00
0bc1ac9161 feat(node-agent): add Dockerfile 2026-05-20 13:47:57 -04:00
feb78b8bcb feat(node-agent): add package.json for cluster heartbeat agent 2026-05-20 13:47:53 -04:00
86b80e043e fix: correct sidebar logo alt text in projects.html (Z-AMPP → Wild Dragon) 2026-05-20 09:17:05 -04:00
398ee8b932 fix: standardize sidebar icons in containers.html (containers/cluster/logout) 2026-05-20 09:15:14 -04:00
44277bced6 fix: standardize sidebar icons in cluster.html (containers/cluster/logout) 2026-05-20 09:14:11 -04:00
ea04b8f9e1 fix: standardize sidebar icons in editor.html (containers/cluster/logout) 2026-05-20 09:12:02 -04:00
ede55a8a5f feat(plugin): add seq info bar, export panel, and 2-row action bar styles 2026-05-20 00:38:59 -04:00
9ba3bf6f83 feat(plugin): add seq info bar, hi-res button, export panel to panel UI
- Active sequence info bar shows current Premiere sequence name
- Import Proxy / Hi-Res split buttons replace single Import button
- Export panel (hidden) slides in with seq name, project picker, clip count
- Export Timeline button in second action row triggers panel
2026-05-20 00:38:09 -04:00
16888d62e2 feat(plugin): proxy URL fix, hi-res import, import path tracking, timeline export
- Fix: /stream returns relative URL — prepend serverUrl before Node.js download
- Add: importAssetHires() calls /assets/:id/hires for original file
- Add: saveImportMapping() stores tempPath→assetId in localStorage so
  timeline export can match Premiere clips back to MAM assets
- Add: startExportTimeline() reads active sequence via exportTimelineData(),
  shows export panel with seq name + clip count
- Add: confirmExportTimeline() resolves paths→assetIds, upserts sequence,
  PUT /sequences/:id/clips
- Add: refreshCurrentSequenceInfo() shows active sequence name in info bar
2026-05-20 00:37:34 -04:00
5bb22c17c8 feat(plugin): add exportTimelineData() and getProjectItems() to ExtendScript
exportTimelineData() walks all video tracks in the active sequence and
returns clip source/timeline frame positions + file paths so the panel JS
can map them back to MAM asset IDs for timeline export.

getProjectItems() enumerates all ProjectItems with paths — useful for
rebuilding the import mapping after a Premiere restart.
2026-05-20 00:35:18 -04:00
a855ea7885 feat(api): add GET /assets/:id/hires endpoint for original file download
Returns presigned S3 URL + filename/ext/file_size for the original
hi-res source so the Premiere plugin can download and import it.
2026-05-20 00:34:18 -04:00
f7aedb1936 fix(ui): normalize sidebar — add Containers + Cluster to all 8 remaining pages 2026-05-20 00:22:57 -04:00
879c547e08 home: add Containers + Cluster cards, fix Editor link, extend loadStats 2026-05-20 00:02:48 -04:00
0c761d553c feat(ui): cluster node registry page — health, CPU, memory, deregister 2026-05-19 23:58:17 -04:00
e3cdf70883 feat(ui): Docker container management page — restart, stop, start 2026-05-19 23:57:23 -04:00
1e9710ce0c feat(editor): thumbnail images in media panel; Del=ripple, Shift+Del=lift 2026-05-19 23:56:23 -04:00
090452969c feat(api): register system + cluster routes; add self-heartbeat on startup 2026-05-19 23:50:19 -04:00
66844b93d3 feat(cluster): node registry API — heartbeat, list, deregister 2026-05-19 23:46:16 -04:00
bd8b492ff6 feat(db): cluster_nodes table for multi-server registry 2026-05-19 23:46:06 -04:00
910a906600 feat(system): Docker container management via Unix socket 2026-05-19 23:46:03 -04:00
89771a2380 feat(timeline): ripple delete on Del, extract/lift on Shift+Del 2026-05-19 23:45:41 -04:00
a5823effe9 feat(assets): add ?redirect=1 to thumbnail endpoint for img src use 2026-05-19 23:44:17 -04:00
36e668455f feat(editor): media-panel search, sequence duration badge, parseFloat guard
- Media panel gains a search input that filters the clip list in real time
  (case-insensitive match on display_name / filename)
- Timeline toolbar shows total sequence duration (e.g. 00:05:23;14) and
  frame rate, updated whenever clips change or a sequence is opened
- parseFloat() guard on state.seq.frame_rate so a NUMERIC string from
  Postgres never leaks into Timeline.render() / applyHistory()
2026-05-19 23:27:25 -04:00
4d0e715982 fix(sequences): coerce NUMERIC frame_rate to float in all API responses
node-postgres returns NUMERIC columns as strings by default.  Add a
mapSeq() helper that parses frame_rate to a JS float before any response
is sent.  Affected routes: GET /, POST /, PUT /:id, GET /:id.
2026-05-19 23:24:16 -04:00
bfc2649909 feat(editor): fps-aware render, FPS selector in new-seq dialog, keyboard help overlay
- openSequence() and applyHistory() now pass state.seq.frame_rate to
  Timeline.render() instead of hardcoded 59.94 — clips render on the
  correct frame grid for every sequence
- New-sequence panel gains a frame-rate selector (23.976 / 24 / 25 /
  29.97 / 30 / 50 / 59.94 / 60); createNewSequence() posts frame_rate
  to the API
- Press ? to open a keyboard shortcut help overlay; Escape to close
2026-05-19 23:20:10 -04:00
81c771a7be feat(jobs): replace polling with SSE EventSource for live job updates
- Drop setTimeout/scheduleRefresh loop in favour of EventSource on
  /api/v1/jobs/events (pushes every 2 s from the server)
- Refresh dot turns green on open, goes grey + "Reconnecting…" on error
  (EventSource auto-reconnects natively)
- Type-filter is now applied client-side against the full SSE payload so
  the dropdown change no longer triggers an HTTP round-trip
- killJob / retryJob / clearCompleted no longer call loadJobs(); the next
  SSE push (≤2 s) reflects the change automatically
2026-05-19 23:17:18 -04:00
16b8530d43 fix: include filename in search; add POST /cleanup-live to recover stuck live assets 2026-05-19 23:10:51 -04:00
8a2ef38326 fix: bulk-fetch jobs by state (no N+1 getState()); add GET /events SSE stream 2026-05-19 23:09:47 -04:00
d382c6b559 fix: EDL export uses sequence frame_rate for timecode (29.97/59.94 DF, others non-drop) 2026-05-19 23:09:17 -04:00
d21c61a8b2 fix: addClip uses s.fps instead of hardcoded TC.secondsToFrames (59.94) 2026-05-19 23:08:13 -04:00
b175eaf54c fix: clean up temp segment directory after conform job finishes 2026-05-19 23:06:54 -04:00
90bb0769e5 fix: correct editor service port typo (47435 → 7435) 2026-05-19 23:06:35 -04:00
07ded22f8e feat: video proxy streaming endpoint + editor drag-and-drop to timeline
- mam-api: add GET /api/v1/assets/:id/video streaming proxy that fetches
  from RustFS/S3 and pipes to browser with range-request support, bypassing
  direct S3 access from Chrome
- mam-api: fix /stream route to return /video proxy URL for both proxy and
  original-mp4 assets; return null cleanly for non-playable sources
- s3/client: set requestChecksumCalculation/responseChecksumValidation to
  WHEN_REQUIRED to suppress x-amz-checksum-mode header on signed URLs
- editor: fix loadSourceAsset to set state.sourceAsset even when no proxy
  exists (info toast instead of bail-out) so Insert/Overwrite still work
- editor: add drag-and-drop from media panel to timeline — items are now
  draggable, timeline container accepts drops and calls Timeline.addClip
  with the asset at playhead position
- editor: add tl-drag-over CSS highlight on timeline during drag
2026-05-19 22:47:33 -04:00
5019563c38 fix: override user-select:none on draggable media items to fix drag initiation
EditorInterface root div has select-none (user-select:none) applied globally
to prevent text selection during editing. Chrome/Safari refuse to start HTML5
drag-and-drop on elements that inherit user-select:none, which is why no
ghost image appeared, cursor never changed, and no dragstart events fired.

Fix: add select-text (user-select:text) to both draggable divs in
MediaThumbnail (list view and grid view). This overrides the inherited none
specifically on the elements that need to be dragged, without changing the
global UX behavior of the editor.
2026-05-19 14:45:47 -04:00
fd6693ee17 fix: remove ContextMenuTrigger asChild from draggable elements to fix drag initiation
With asChild, Radix merges its pointer event handlers directly onto the
draggable div. This interferes with browser drag gesture initiation,
resulting in no ghost image and no drag events firing.

Fix: remove asChild so ContextMenuTrigger renders its own span (with
display:contents to preserve layout). Radix handlers now live on the
ancestor span, not the draggable div. Right-click still bubbles up to
trigger the context menu correctly.

Also add draggable={false} to <img> elements inside draggable divs
to prevent browser native image drag from competing with the parent.
2026-05-19 13:00:09 -04:00
18c4779f58 fix: add onDragEnd to AssetsPanel to clear isDragging state
- Import endDrag from useUIStore
- Add handleItemDragEnd callback that calls endDrag()
- Add onDragEnd? prop to MediaThumbnail interface
- Wire onDragEnd={onDragEnd} to both draggable divs (list & grid views)
- Pass onDragEnd={handleItemDragEnd} when rendering MediaThumbnail
- Without this, isDragging was permanently stuck at true after every drag
2026-05-19 11:20:29 -04:00
aec55fea83 fix: await onDropMedia, fix stale closure deps, surface errors in TrackLane
- Import ActionResult type from @openreel/core
- onDropMedia prop type now returns Promise<ActionResult> | void
- handleDrop now awaits onDropMedia so failures are visible
- Replace silent catch with console.error + toast.error on failure
- Add allTracks, playheadPosition, snapSettings to handleDrop useCallback deps
  to fix stale closure bug (calculateSnap was using stale snap/track state)
2026-05-19 11:12:09 -04:00
76e6568ac6 fix: await handleDropMedia and surface clip-add errors in Timeline
- handleDropMedia now returns the ActionResult from addClip/addClipToNewTrack
- The tracksRef onDrop handler now awaits handleDropMedia so errors aren't silently lost
- Replaces the swallowed catch block with a toast.error + console.error on failure
- This makes clip-add failures visible instead of silently doing nothing
2026-05-19 11:11:17 -04:00
43a17ecd14 feat(jobs): add Retry button for failed jobs with an associated asset 2026-05-19 00:54:47 -04:00
de4cb1b6a0 fix(tokens): add version cache-busters to api.js and topbar-strip.js 2026-05-19 00:51:47 -04:00
4407e8ce6d fix(edit): add version cache-busters to api.js and topbar-strip.js 2026-05-19 00:48:50 -04:00
36f165807a fix(topbar-strip): escape pageName() output before innerHTML insertion 2026-05-19 00:46:48 -04:00
76b0a5e05e fix(recorders): escape d.error in renderProbeResult to prevent XSS 2026-05-19 00:46:12 -04:00
9c83698b81 feat: inline rename on double-click in library asset cards
Double-clicking a clip name in the library shows an in-place text input.
Enter/blur commits the new display_name via PATCH; Escape cancels.
Clicking the card body or action buttons still work normally.
2026-05-19 00:41:43 -04:00
f39d086bc8 fix: add cache-buster version strings to api.js and topbar-strip.js in home.html 2026-05-19 00:39:24 -04:00
1e4fcb62f5 feat: add status filter chips and sort controls to library
Adds an "All / Ready / Processing / Error / Live" pill filter row and
a "Newest / Oldest / Name / Duration / Size" sort selector to the asset
toolbar. Both operate client-side on the loaded asset list so there is
no additional API overhead. State resets to "All / Newest" whenever a
different project or bin is selected.
2026-05-19 00:35:23 -04:00
08e8377309 fix: bump api.js cache-buster to v=6 in upload.html 2026-05-19 00:33:11 -04:00
280fc9dff2 fix: XSS in renderTags and stale api.js version in player.html
Tag values were inserted into innerHTML unsanitized — a tag containing
HTML would execute as markup. Switch to DOM-only construction for the
tag badges. Also bump api.js cache-buster to v=6.
2026-05-19 00:30:54 -04:00
f1e0453b0a fix: bump api.js cache-buster to v=6 in capture.html 2026-05-19 00:28:50 -04:00
9f7cb91cc2 fix: prevent JS injection via token name in confirmRevoke onclick
Token names containing single quotes (e.g. "O'Brien's key") broke the
onclick attribute string by closing the JS string literal early.
Apply JSON.stringify+esc pattern so name is safely embedded as a
JSON string literal instead of a raw single-quoted string.
2026-05-19 00:27:31 -04:00
f8e42b886d fix(sequences): apply correct 59.94 DF framesToTC to EDL export
sequences.js had the same `if (rem >= DROP)` bug as timecode.js — any
frame ≥ 4 in the first non-drop minute of each 10-minute group would
produce a timecode offset by one minute. EDL files exported from the
editor would have wrong in/out points for nearly every event.

Applies the FRAMES_FIRST_MIN (3600) boundary check fix, matching the
correction already made to services/web-ui/public/js/timecode.js.
2026-05-19 00:22:17 -04:00
d18fa2f761 feat(library): add Retry button for error-status assets in library grid
Error assets now show an amber circular-arrow action button on hover.
Clicking it calls POST /api/v1/assets/:id/retry, resets status to
'processing', and refreshes the grid — no manual DB intervention needed
when a proxy job fails.
2026-05-19 00:20:19 -04:00
130906ef42 feat(api.js): add retryAsset() helper for POST /assets/:id/retry 2026-05-19 00:17:39 -04:00
d3e12deb18 feat(assets): add POST /:id/retry to re-queue errored assets
Assets stuck in status='error' had no recovery path without manual DB
edits. Adds a retry endpoint that re-dispatches the proxy job, which
chains into thumbnail generation automatically and restores the asset
to 'processing' → 'ready' without operator intervention.
2026-05-19 00:17:00 -04:00
2bb731c7fc fix(users): prevent JS injection in delete onclick handlers for users/groups
confirmDeleteUser and confirmDeleteGroup were building onclick handlers
like onclick="confirmDeleteUser('id','NAME')" using esc() which doesn't
escape single quotes.  Usernames or group names containing ' would break
the JS string; a crafted value like `'; alert(1)//` is stored XSS.

Fix: use JSON.stringify(value) to produce a properly-escaped double-quoted
JS string literal, then esc() to HTML-encode the surrounding quotes for
safe embedding in the HTML attribute.  Same technique now used in both
renderUsers() and renderGroups().
2026-05-19 00:11:06 -04:00
1e8cde81be fix(projects): prevent JS injection via bin names in onclick handlers
binCard() was building onclick="renameBinPrompt('id', 'NAME')" by
calling esc() then .replace(/'/g, "\\'").  The problem: esc() converts
' to &#39;, so the replace never fires on raw single quotes.  When the
HTML parser evaluates the attribute it decodes &#39; back to ', breaking
the JS string — and for injected payloads like `'; alert(1)//` this is
stored XSS.

Fix: use JSON.stringify(b.name) to produce a properly-escaped double-
quoted JS string literal, then esc() to HTML-encode the surrounding
double-quotes for safe embedding in the HTML attribute.
2026-05-19 00:09:49 -04:00
58e2e539f8 fix(upload): scope original S3 keys under assetId to prevent collisions
Both /init and /simple were keying originals as
`originals/${projectId}/${filename}`.  Two uploads of the same filename
into the same project would share a key — the second upload would silently
overwrite the first file in S3 while both assets remained in the DB with
the same original_s3_key.

Changed to `originals/${assetId}/${filename}` (matching the proxies/
convention) so every asset has its own unique S3 prefix.
2026-05-19 00:08:13 -04:00
4f8964e807 fix(tokens): add requireAuth middleware to token routes
Token CRUD endpoints had no authentication guard.  Without it,
unauthenticated requests could reach the handler — GET would return
empty results silently, and POST could attempt to insert a token with
user_id = NULL.  All other route files in this codebase apply
requireAuth explicitly; tokens.js was simply missing it.
2026-05-19 00:07:41 -04:00
0ea8d7ce33 fix(timeline): cap right-trim at source asset boundary
When duration_ms is known, dragging the right-trim handle past the end
of the source clip could push timeline_out_frames beyond what the source
material covers.  Cap the delta so neither timeline_out_frames nor
source_out_frames can extend past the available source frames.

Also changed assetFrames fallback from origSrcOut (prevents any extension
when duration is unknown) to null, so the guard is simply skipped when
we don't have duration metadata.
2026-05-19 00:02:34 -04:00
3c689ccddf fix(timecode): correct framesToTC for all frames beyond position 3
The previous algorithm used `if (rem >= DROP)` (i.e. rem >= 4) to decide
whether to advance to the next minute group.  This fired immediately at
frame 4, still inside minute 0 of the 10-minute non-drop group, producing
00:01:00;00 for what should be 00:00:00;04.  Every timecode display in
the editor was wrong for any position past the first four frames.

Each 10-minute block has one 3600-frame non-drop minute followed by nine
3596-frame drop minutes.  The fix checks `rem < FRAMES_FIRST_MIN` (3600)
to identify the non-drop minute, then subtracts it before dividing into
drop-minute slots.  Frame labels within drop minutes are shifted by DROP
(+4) so the first usable label is :00;04 as per SMPTE 12M.
2026-05-19 00:01:18 -04:00
b23700f30a fix(recorders): use already-imported uuidv4 instead of dynamic import
Dynamic `(await import('uuid')).v4()` inside the /start route handler
re-imports the module every call (though Node caches it). uuidv4 is
already imported at the top of the file.
2026-05-18 23:56:00 -04:00
0f37d01b2d fix(editor): keyboard tool shortcuts now actually switch the active tool
V/C/H key shortcuts called updateToolbarActive() which only updated button
visual state — Timeline.setTool() was never called so the cursor stayed on
the previous tool. Fix by calling Timeline.setTool() inside updateToolbarActive.

Also bump api.js reference to ?v=6 to match other pages.
2026-05-18 23:53:38 -04:00
fb3b998cfd fix(worker/thumbnail): mark asset ready even when thumbnail extraction fails
If the thumbnail job throws (network blip, ffmpeg error, short clip), the
asset was left stuck in status='processing' indefinitely. Since the proxy
already exists and the asset is playable, set status='ready' in the catch
block before re-throwing so BullMQ can still record the failure.
2026-05-18 23:51:04 -04:00
ff892a1ad5 fix(capture): use duration_ms field for recent captures duration display
The asset schema stores duration as duration_ms (milliseconds).
renderRecent() was checking c.duration (always undefined) so duration
always showed as '—'. Fix to use c.duration_ms / 1000.
2026-05-18 23:50:05 -04:00
08e5ba6298 fix(jobs): fetchJobs → loadJobs, add credentials to inline api helper
killJob() referenced fetchJobs() which is undefined — the correct name is
loadJobs(). Also the inline api() wrapper was missing credentials:'include'
so any API call on the jobs page would fail with a 401 in prod.
2026-05-18 23:48:56 -04:00
e472075087 fix(library): evict stale thumb URL on image load error, re-observe for retry
When a signed S3 URL expires the img fires onerror. Previously the stale URL
stayed in thumbCache so the broken image would persist. Now we delete the cache
entry, clear the loaded class, and re-add the element to the IntersectionObserver
so the next time it scrolls into view a fresh signed URL is fetched.
2026-05-18 23:46:12 -04:00
e6314be92d fix(assets): strip internal full_count column from list response
The window function COUNT(*) OVER() leaks `full_count` on every row.
Strip it before sending so callers only see actual asset fields.
2026-05-18 23:44:14 -04:00
660afb94bb feat(editor): show fps/codec/resolution/duration in media panel asset list
- Add two-line layout to media panel items: name on top, metadata below
- fmtMs() converts duration_ms to MM:SS or HH:MM:SS for display
- Meta line shows resolution · codec · fps · duration, skipping null fields
- Assets with no extracted metadata (no proxy yet) show name only
- Active item meta line inherits accent color at reduced opacity
2026-05-18 23:37:56 -04:00
508cf8d41b feat(recorders): add Edit Recorder panel with PATCH support
- Edit (pencil) button appears on idle recorder cards; hidden while recording
- openEditPanel() pre-populates all form fields from existing recorder state
- openPanel() resets editingId and restores "New recorder" defaults
- closePanel() clears editingId and removes any stale probe result
- handleSaveRecorder() dispatches PATCH /recorders/:id in edit mode, POST otherwise
- Fix field name bugs in create path: codec→recording_codec, resolution→recording_resolution,
  proxy_config object→proxy_enabled/proxy_codec/proxy_resolution flat fields
- Badge in card now reads rec.recording_codec (correct DB field) instead of rec.codec
- Bump api.js cache-buster to v=6
2026-05-18 23:35:16 -04:00
79d44826fe feat(api.js): add patchRecorder() helper for PATCH /recorders/:id 2026-05-18 23:32:33 -04:00
7260b188c5 fix: remove dead DB UPDATE calls in conform worker
The jobs table row no longer exists for conform jobs (POST /jobs/conform
now goes directly to BullMQ). The UPDATE queries were no-ops (WHERE id = NULL)
so they're safe to remove. BullMQ tracks completed/failed status itself.
2026-05-18 23:28:13 -04:00
e895a2f2df fix: show duration overlay on asset cards using duration_ms
asset.duration is not a DB field — it's duration_ms (milliseconds).
Divide by 1000 before passing to formatDuration() which expects seconds.
2026-05-18 23:27:03 -04:00
a9ca7be1d5 feat: add PATCH /recorders/:id endpoint to edit recorder settings
Allows updating name, source_type, source_config, recording_codec,
recording_resolution, proxy_enabled, proxy_codec, proxy_resolution,
and project_id. Blocked while the recorder is actively recording.
2026-05-18 23:24:27 -04:00
29b5910fff feat: migrate editor sequences schema into auto-run migrations directory
Moved from schema_patch_editor.sql. All statements are idempotent
(IF NOT EXISTS / DO $$ BEGIN blocks) so safe to re-apply.
2026-05-18 23:23:33 -04:00
ffad0051f9 feat: migrate groups/tokens schema into auto-run migrations directory
Moved from schema_patch_groups_tokens.sql. All statements are idempotent
(IF NOT EXISTS / CREATE INDEX IF NOT EXISTS) so safe to re-apply.
2026-05-18 23:23:23 -04:00
717fdcd611 feat: extract and store fps/codec/resolution/duration_ms from source file
Uses getMediaInfo (ffprobe) on the downloaded original before transcoding.
Populates the asset record so the library can display accurate metadata.
2026-05-18 23:22:56 -04:00
817eaff8b1 feat: add getMediaInfo to executor.js using ffprobe JSON output
Exposes video stream fps/codec/resolution and container duration/size
so the proxy worker can populate asset metadata after transcoding.
2026-05-18 23:22:26 -04:00
48b69879cb fix: conform route broken SQL — remove dead DB insert, use BullMQ directly
The POST /conform route was inserting into the jobs table with non-existent
columns (project_id, metadata) and an invalid enum value ('pending'). Since
GET /jobs reads entirely from BullMQ, the DB insert was both incorrect and
redundant. Now we just enqueue the BullMQ job and return its ID.
2026-05-18 23:22:14 -04:00
596f755a6c fix: remove stray Wild Dragon brand remnant from editor.html 2026-05-18 23:14:14 -04:00
936 changed files with 53042 additions and 227330 deletions

View file

@ -22,5 +22,51 @@ SESSION_SECRET=changeme
# MAM API Configuration
MAM_API_URL=http://mam-api:3000
# Auth (set to 'true' to require login; false for open/dev mode)
AUTH_ENABLED=false
# 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

12
.gitignore vendored
View file

@ -14,6 +14,9 @@ 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
@ -23,3 +26,12 @@ services/editor/node_modules
services/editor/**/node_modules
services/editor/**/dist
services/editor/.pnpm-store
# Blackmagic DeckLink SDK + runtime libs (operator-supplied; see services/capture/build-with-decklink.sh)
services/capture/sdk/
services/capture/lib/
# Editor backups
*.bak
*.bak2
.env.bak.*

153
DESIGN.md Normal file
View file

@ -0,0 +1,153 @@
# 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.

61
PRODUCT.md Normal file
View file

@ -0,0 +1,61 @@
# 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.

264
README.md
View file

@ -1,60 +1,258 @@
# Wild Dragon
# Dragonflight
Self-hosted Media Asset Management platform built to replace Grass Valley AMPP FramelightX.
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.
## Services
> Repo renamed from `wild-dragon``dragonflight` (2026-05-23). The old URL still redirects.
| Service | Port | Description |
|---------|------|-------------|
| **web-ui** | 8080 | Browser-based MAM interface + capture controls |
| **mam-api** | 3000 | REST API — assets, projects, bins, jobs |
| **capture** | 3001 | SDI capture daemon (Blackmagic DeckLink + FFmpeg) |
| **worker** | — | Async job processor (proxy gen, thumbnails, conform) |
| **db** | 5432 | PostgreSQL 16 metadata store |
| **queue** | 6379 | Redis 7 job queue (BullMQ) |
## Home Dashboard
<img src="docs/screenshots/01-home.png" alt="Home Dashboard" width="800" />
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
---
## Quick Start
```bash
# Clone
git clone https://forge.wilddragon.net/zgaetano/wild-dragon.git
cd wild-dragon
# Clone (repo renamed; old URL still redirects)
git clone https://forge.wilddragon.net/zgaetano/dragonflight.git
cd dragonflight
# Configure
cp .env.example .env
# Edit .env with your S3 credentials and secrets
# Edit .env — S3 credentials + SESSION_SECRET at minimum
# Launch
docker compose up -d
# Open
open http://localhost:8080
open http://localhost:47434
```
## Architecture
```
SDI Input (DeckLink) → capture service → dual FFmpeg streams
├─ HiRes (ProRes) → S3
└─ Proxy (H.264) → S3
web-ui ← mam-api ← PostgreSQL ← worker (BullMQ)
├─ proxy_gen
├─ thumbnail
└─ conform (EDL → FFmpeg → export)
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
- **Backend:** Node.js / Express
- **Frontend:** Vanilla HTML/CSS/JS
- **Database:** PostgreSQL 16
- **Queue:** Redis 7 + BullMQ
- **Storage:** S3-compatible (RustFS)
- **Media Processing:** FFmpeg
- **Capture:** Blackmagic DeckLink SDK
- **Deployment:** Docker Compose
- **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
## 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.
---
## License
MIT
Proprietary — Wild Dragon LLC, all rights reserved.

101
WORK_LOG_PLAYOUT.md Normal file
View file

@ -0,0 +1,101 @@
# 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.

104
deploy/api-smoke.sh Executable file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Dragonflight MAM API smoke test
#
# Hits every read-only endpoint and a handful of safe write endpoints
# against a running mam-api. Reports per-endpoint HTTP code + a one-line
# pass/fail. Exits non-zero on any failure.
#
# Usage:
# deploy/api-smoke.sh # against http://localhost:47432
# API=http://10.0.0.25:47432 deploy/api-smoke.sh
set -u
API="${API:-http://localhost:47432}"
PASS=0
FAIL=0
# Per-endpoint check. Args: METHOD PATH EXPECTED_HTTP_CODE [BODY]
# Treats anything < 500 as OK by default; auth-gated endpoints typically
# return 401 with AUTH_ENABLED, also acceptable.
hit() {
local method="$1" path="$2" expect="${3:-2..}" body="${4:-}"
local args=(-s -o /dev/null -w '%{http_code}' -X "$method" "${API}${path}")
if [ -n "$body" ]; then args+=(-H 'Content-Type: application/json' -d "$body"); fi
local code
code=$(curl "${args[@]}" 2>/dev/null || echo "000")
if [[ "$code" =~ ^(2|3|401|400)[0-9][0-9]$ ]]; then
printf " %s %-40s %s OK\n" "$method" "$path" "$code"
PASS=$((PASS + 1))
else
printf " %s %-40s %s FAIL\n" "$method" "$path" "$code"
FAIL=$((FAIL + 1))
fi
}
echo "Dragonflight API smoke test — target ${API}"
echo ""
echo "── auth ──────────────────────────────────────────"
hit GET /api/v1/auth/me
echo ""
echo "── core lists ────────────────────────────────────"
hit GET /api/v1/projects
hit GET /api/v1/assets
hit GET /api/v1/assets?limit=5
hit GET /api/v1/recorders
hit GET /api/v1/jobs
hit GET /api/v1/bins
hit GET /api/v1/users
hit GET /api/v1/groups
hit GET /api/v1/cluster
hit GET /api/v1/cluster/containers
hit GET /api/v1/cluster/devices/blackmagic
echo ""
echo "── settings ──────────────────────────────────────"
hit GET /api/v1/settings/s3
hit GET /api/v1/settings/transcoding
hit GET /api/v1/settings/growing
hit GET /api/v1/settings/ampp
hit GET /api/v1/settings/hardware
hit GET /api/v1/settings/capture-service
echo ""
echo "── feature endpoints ─────────────────────────────"
hit GET /api/v1/metrics/home
hit GET /api/v1/metrics/home?hours=1
hit GET /api/v1/schedules
hit GET /api/v1/schedules?status=upcoming
hit GET /api/v1/sdk
echo ""
echo "── deep-link sanity (one asset) ──────────────────"
ASSET_ID=$(curl -s "${API}/api/v1/assets?limit=1" 2>/dev/null \
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
if [ -n "$ASSET_ID" ]; then
echo " using asset_id=$ASSET_ID"
hit GET "/api/v1/assets/$ASSET_ID"
hit GET "/api/v1/assets/$ASSET_ID/comments"
hit GET "/api/v1/assets/$ASSET_ID/stream"
hit GET "/api/v1/assets/$ASSET_ID/thumbnail"
else
echo " (no assets to deep-link; skipping per-asset endpoints)"
fi
echo ""
echo "── deep-link sanity (one recorder) ───────────────"
REC_ID=$(curl -s "${API}/api/v1/recorders" 2>/dev/null \
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
if [ -n "$REC_ID" ]; then
echo " using recorder_id=$REC_ID"
hit GET "/api/v1/recorders/$REC_ID"
hit GET "/api/v1/recorders/$REC_ID/status"
else
echo " (no recorders to deep-link)"
fi
echo ""
echo "── summary ───────────────────────────────────────"
echo " PASS: $PASS"
echo " FAIL: $FAIL"
[ "$FAIL" -eq 0 ]

197
deploy/onboard-node.sh Normal file
View file

@ -0,0 +1,197 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — Cluster Node Onboarding
# =============================================================================
#
# Provisions a Linux machine as a cluster worker node in one command.
#
# Quick-start (pipe to bash):
# export MAM_API_URL=http://10.0.0.25:47432
# export NODE_TOKEN=wd_xxxx # create via Z-AMPP → Admin → Tokens
# curl -sL https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh | bash
#
# Or run from a cloned repo:
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/onboard-node.sh
#
# Environment variables:
# MAM_API_URL REQUIRED Primary MAM API base URL
# NODE_TOKEN API bearer token (required if AUTH_ENABLED=true)
# NODE_ROLE Role tag reported to the cluster (default: worker)
# NODE_IP Override the LAN IP reported back (default: auto-detect)
# AGENT_PORT Host port for the node agent (default: 7436)
# INSTALL_DIR Where to clone/find the repo (default: /opt/wild-dragon)
# PROFILES Extra compose profiles, space-sep e.g. "worker capture"
# BMD_MODEL DeckLink card model name (e.g. "DeckLink Duo 2")
# REPO_URL Override the Forgejo clone URL
# =============================================================================
set -euo pipefail
# ── Config ───────────────────────────────────────────────────────────────────
REPO_URL="${REPO_URL:-https://forge.wilddragon.net/zgaetano/wild-dragon.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/wild-dragon}"
MAM_API_URL="${MAM_API_URL:-}"
NODE_TOKEN="${NODE_TOKEN:-}"
NODE_ROLE="${NODE_ROLE:-worker}"
NODE_IP="${NODE_IP:-}"
AGENT_PORT="${AGENT_PORT:-7436}"
PROFILES="${PROFILES:-}"
BMD_MODEL="${BMD_MODEL:-}"
PROJECT_NAME="wild-dragon-worker"
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'
BLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GRN}${NC} $*"; }
info() { echo -e "${CYN}${NC} $*"; }
warn() { echo -e "${YEL}${NC} $*"; }
header() { echo -e "\n${BLD}${CYN}── $* ──────────────────────────────────────${NC}"; }
die() { echo -e "${RED} ✗ ERROR:${NC} $*" >&2; exit 1; }
# ── Auto-detect LAN IP ───────────────────────────────────────────────────────
# Node-agent runs in a container; os.networkInterfaces() inside the container
# returns the docker-bridge IP unless we pass NODE_IP through. We resolve the
# host's primary LAN IP here so the cluster page shows the right address.
detect_lan_ip() {
local ip=""
if command -v ip &>/dev/null; then
ip=$(ip -4 route get 1.1.1.1 2>/dev/null \
| awk '/src/ {for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' \
|| true)
fi
if [[ -z "$ip" ]] && command -v hostname &>/dev/null; then
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
fi
echo "$ip"
}
# ── Preflight ────────────────────────────────────────────────────────────────
echo -e "\n${BLD}${CYN}Wild Dragon MAM — Cluster Node Onboarding${NC}\n"
[[ -z "$MAM_API_URL" ]] && die "MAM_API_URL is required.\n\n Example:\n export MAM_API_URL=http://10.0.0.25:47432\n export NODE_TOKEN=wd_xxxx\n ./deploy/onboard-node.sh"
if [[ -z "$NODE_IP" ]]; then
NODE_IP="$(detect_lan_ip)"
if [[ -n "$NODE_IP" ]]; then
info "Auto-detected LAN IP: $NODE_IP"
else
warn "Could not auto-detect LAN IP — agent will fall back to interface heuristics."
fi
fi
info "Primary API : $MAM_API_URL"
info "Role : $NODE_ROLE"
info "Agent port : $AGENT_PORT"
info "Install dir : $INSTALL_DIR"
[[ -n "$NODE_IP" ]] && info "Node IP : $NODE_IP"
[[ -n "$BMD_MODEL" ]] && info "DeckLink : $BMD_MODEL"
[[ -n "$PROFILES" ]] && info "Profiles : $PROFILES"
if [[ -z "$NODE_TOKEN" ]]; then
warn "NODE_TOKEN is not set."
warn "If AUTH_ENABLED=true on the primary, heartbeats will be rejected."
warn "Create a token: Z-AMPP web UI → Admin → Tokens → New Token"
fi
# ── Step 1: Docker ───────────────────────────────────────────────────────────
header "1/4 Docker"
if ! command -v docker &>/dev/null; then
warn "Docker not found — installing via get.docker.com"
curl -fsSL https://get.docker.com | bash
systemctl enable --now docker 2>/dev/null || true
usermod -aG docker "${SUDO_USER:-$USER}" 2>/dev/null || true
log "Docker installed"
else
log "Docker $(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1) already installed"
fi
if ! docker info &>/dev/null; then
die "Docker daemon not accessible.\n Try: sudo systemctl start docker\n Or add your user to the docker group and re-login."
fi
# ── Step 2: Repository ───────────────────────────────────────────────────────
header "2/4 Repository"
if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Updating $INSTALL_DIR"
git -C "$INSTALL_DIR" pull --ff-only
log "Repository up to date ($(git -C "$INSTALL_DIR" rev-parse --short HEAD))"
else
info "Cloning $REPO_URL$INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone "$REPO_URL" "$INSTALL_DIR"
log "Repository cloned"
fi
# ── Step 3: Environment ──────────────────────────────────────────────────────
header "3/4 Configuration"
ENV_FILE="$INSTALL_DIR/.env.worker"
info "Writing $ENV_FILE"
{
echo "# Wild Dragon worker node — generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by onboard-node.sh"
echo "MAM_API_URL=$MAM_API_URL"
echo "NODE_TOKEN=$NODE_TOKEN"
echo "NODE_ROLE=$NODE_ROLE"
echo "NODE_IP=$NODE_IP"
echo "AGENT_PORT=$AGENT_PORT"
echo "HEARTBEAT_MS=30000"
[[ -n "$BMD_MODEL" ]] && echo "BMD_MODEL=$BMD_MODEL"
for v in REDIS_URL DATABASE_URL S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY S3_SECRET_KEY S3_REGION; do
val="${!v:-}"
[[ -n "$val" ]] && echo "$v=$val"
done
} > "$ENV_FILE"
log "Env file written"
# ── Step 4: Start services ───────────────────────────────────────────────────
header "4/4 Starting services"
COMPOSE="docker compose -f $INSTALL_DIR/docker-compose.worker.yml --env-file $ENV_FILE --project-name $PROJECT_NAME"
PROFILE_FLAGS=""
for p in $PROFILES; do
PROFILE_FLAGS="$PROFILE_FLAGS --profile $p"
done
info "Building images (this may take a minute on first run)…"
$COMPOSE build
info "Starting containers…"
# shellcheck disable=SC2086
$COMPOSE $PROFILE_FLAGS up -d
# ── Verify ───────────────────────────────────────────────────────────────────
echo ""
info "Waiting 6 seconds for agent to initialise…"
sleep 6
HEALTH_URL="http://localhost:$AGENT_PORT/health"
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
log "Node agent healthy at $HEALTH_URL"
REPORTED_IP=$(curl -sf "$HEALTH_URL" | sed -nE 's/.*"ip":"([^"]+)".*/\1/p')
[[ -n "$REPORTED_IP" ]] && log "Reporting IP: $REPORTED_IP"
else
warn "Could not reach $HEALTH_URL — check logs:"
warn " $COMPOSE logs node-agent"
fi
# ── Done ─────────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}${GRN}Onboarding complete!${NC}"
echo ""
echo -e " Node agent ${BLD}:$AGENT_PORT${NC} (heartbeating every 30s)"
echo -e " Primary API ${BLD}$MAM_API_URL${NC}"
echo -e " Role ${BLD}$NODE_ROLE${NC}"
[[ -n "$NODE_IP" ]] && echo -e " Node IP ${BLD}$NODE_IP${NC}"
echo ""
echo -e " ${CYN}Useful commands:${NC}"
echo -e " Status : $COMPOSE ps"
echo -e " Logs : $COMPOSE logs -f"
echo -e " Stop : $COMPOSE down"
echo -e " Update : git -C $INSTALL_DIR pull && $COMPOSE build && $COMPOSE up -d"
echo ""
echo -e " Open the Z-AMPP web UI → ${BLD}Admin → Cluster${NC} to see this node."

186
deploy/test-api.sh Normal file
View file

@ -0,0 +1,186 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — API Smoke Test
# =============================================================================
# Hits every major endpoint and reports pass/fail.
#
# Usage:
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-api.sh
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/test-api.sh
# =============================================================================
set -euo pipefail
BASE="${MAM_API_URL:-http://localhost:47432}"
TOKEN="${NODE_TOKEN:-}"
PASS=0; FAIL=0; SKIP=0
GRN='\033[0;32m'; RED='\033[0;31m'; YEL='\033[1;33m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
pass() { PASS=$((PASS+1)); echo -e " ${GRN}PASS${NC} $1"; }
fail() { FAIL=$((FAIL+1)); echo -e " ${RED}FAIL${NC} $1 ${RED}$2${NC}"; }
skip() { SKIP=$((SKIP+1)); echo -e " ${YEL}SKIP${NC} $1 ${YEL}($2)${NC}"; }
header() { echo -e "\n${BLD}$1${NC}"; }
AUTH_ARGS=()
[[ -n "$TOKEN" ]] && AUTH_ARGS+=(-H "Authorization: Bearer $TOKEN")
# GET — check HTTP status code (no -f so 4xx/5xx are visible as their real code)
check_status() {
local label="$1" path="$2" want="$3"
local got
got=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null)
[[ -z "$got" ]] && got="000"
if [[ "$got" == "$want" ]]; then
pass "$label [HTTP $got]"
else
fail "$label [HTTP $got]" "expected $want"
fi
}
# GET — check response body contains literal string (fgrep avoids regex interpretation)
check_body() {
local label="$1" path="$2" needle="$3"
local body
body=$(curl -s "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null) || { fail "$label" "request failed"; return; }
if echo "$body" | grep -qF "$needle"; then
pass "$label"
else
fail "$label" "'$needle' not in response"
fi
}
# POST — check HTTP status code
check_post() {
local label="$1" path="$2" data="$3" want="$4"
local got
got=$(curl -s -o /dev/null -w "%{http_code}" \
"${AUTH_ARGS[@]}" \
-H "Content-Type: application/json" \
-X POST -d "$data" \
"$BASE$path" 2>/dev/null)
[[ -z "$got" ]] && got="000"
if [[ "$got" == "$want" ]]; then
pass "$label [HTTP $got]"
else
fail "$label [HTTP $got]" "expected $want"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}${CYN}Wild Dragon MAM — API Smoke Test${NC}"
echo -e " Base URL : ${BLD}$BASE${NC}"
[[ -n "$TOKEN" ]] && echo -e " Auth : Bearer token" || echo -e " Auth : none"
echo ""
# ── Connectivity ─────────────────────────────────────────────────────────────
header "Connectivity"
CONNECT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null)
if [[ "$CONNECT" == "200" ]]; then
pass "API server reachable [/health → 200]"
else
fail "API server reachable [HTTP $CONNECT]" "cannot reach $BASE"
echo -e "\n ${RED}Cannot reach the server — aborting.${NC}"
exit 1
fi
# ── Auth ─────────────────────────────────────────────────────────────────────
header "Auth"
check_status "GET /auth/me" "/api/v1/auth/me" 200
check_body "GET /auth/me returns username" "/api/v1/auth/me" '"username"'
check_post "POST /auth/login (missing body → 400)" "/api/v1/auth/login" '{}' 400
# ── Assets ───────────────────────────────────────────────────────────────────
header "Assets"
check_status "GET /assets" "/api/v1/assets" 200
check_body "GET /assets returns assets key" "/api/v1/assets" '"assets"'
check_status "GET /assets bogus id → 404" "/api/v1/assets/00000000-0000-0000-0000-000000000000" 404
# ── Projects ─────────────────────────────────────────────────────────────────
header "Projects"
check_status "GET /projects" "/api/v1/projects" 200
check_body "GET /projects returns array" "/api/v1/projects" '['
# ── Jobs ─────────────────────────────────────────────────────────────────────
header "Jobs"
check_status "GET /jobs" "/api/v1/jobs" 200
check_body "GET /jobs returns array" "/api/v1/jobs" '['
# ── Recorders ────────────────────────────────────────────────────────────────
header "Recorders"
check_status "GET /recorders" "/api/v1/recorders" 200
# ── Sequences (requires project_id param) ────────────────────────────────────
header "Sequences"
check_status "GET /sequences (no project_id → 400)" "/api/v1/sequences" 400
check_status "GET /sequences bogus project_id → 200" "/api/v1/sequences?project_id=00000000-0000-0000-0000-000000000000" 200
# ── Settings ─────────────────────────────────────────────────────────────────
header "Settings"
check_status "GET /settings/ampp" "/api/v1/settings/ampp" 200
# ── Cluster ──────────────────────────────────────────────────────────────────
header "Cluster"
check_status "GET /cluster" "/api/v1/cluster" 200
check_body "GET /cluster returns array" "/api/v1/cluster" '['
# Heartbeat: register a temporary smoke-test node, verify it appears, remove it
TEST_HOST="smoke-test-$(date +%s)"
check_post "POST /cluster/heartbeat" "/api/v1/cluster/heartbeat" \
"{\"hostname\":\"$TEST_HOST\",\"role\":\"smoketest\",\"cpu_usage\":0,\"mem_used_mb\":512,\"mem_total_mb\":4096}" \
200
NODE_ID=$(curl -s "${AUTH_ARGS[@]}" "$BASE/api/v1/cluster" 2>/dev/null \
| grep -o '"id":"[^"]*"' | head -1 | grep -o '[0-9a-f-]\{36\}' || true)
if [[ -n "$NODE_ID" ]]; then
pass "Cluster node visible in registry"
DEL=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
-X DELETE "$BASE/api/v1/cluster/$NODE_ID" 2>/dev/null)
[[ "$DEL" == "200" ]] && pass "DELETE /cluster/:id (cleanup) [HTTP $DEL]" \
|| fail "DELETE /cluster/:id (cleanup)" "HTTP $DEL"
else
skip "Cluster node visible in registry" "could not parse node id from response"
fi
# ── System / Containers ───────────────────────────────────────────────────────
header "System"
check_status "GET /system/containers" "/api/v1/system/containers" 200
check_body "Containers returns array" "/api/v1/system/containers" '['
# ── Capture (proxies to capture service) ─────────────────────────────────────
header "Capture"
# 200 = capture active and responding
# 404 = capture in sidecar/idle mode (no active recorder — expected in dev)
# 5xx = capture container unreachable
CAPTURE_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
"$BASE/api/v1/capture/status" 2>/dev/null)
if [[ "$CAPTURE_CODE" == "200" ]]; then
pass "GET /capture/status [HTTP 200 — capture active]"
elif [[ "$CAPTURE_CODE" == "404" ]]; then
skip "GET /capture/status [HTTP 404]" "capture in idle/sidecar mode (normal when not recording)"
elif [[ "$CAPTURE_CODE" =~ ^5 ]]; then
skip "GET /capture/status [HTTP $CAPTURE_CODE]" "capture container unreachable"
else
fail "GET /capture/status [HTTP $CAPTURE_CODE]" "unexpected status"
fi
# ── Users / Tokens ───────────────────────────────────────────────────────────
header "Users / Tokens"
check_status "GET /users" "/api/v1/users" 200
check_status "GET /tokens" "/api/v1/tokens" 200
# ── Summary ───────────────────────────────────────────────────────────────────
TOTAL=$((PASS + FAIL + SKIP))
echo ""
echo -e "${BLD}Results:${NC} ${GRN}${PASS} passed${NC} / ${RED}${FAIL} failed${NC} / ${YEL}${SKIP} skipped${NC} / $TOTAL total"
echo ""
if [[ $FAIL -gt 0 ]]; then
echo -e "${RED}Some tests failed — check output above.${NC}"
exit 1
else
echo -e "${GRN}All tests passed.${NC}"
fi

183
deploy/test-cluster.sh Executable file
View file

@ -0,0 +1,183 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — Cluster Smoke Test
# =============================================================================
#
# Validates the cluster end-to-end from any node that can reach the primary.
# Designed to be run after `onboard-node.sh` finishes on every worker.
#
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-cluster.sh
# MAM_API_URL=... AUTH_TOKEN=wd_xxxx ./deploy/test-cluster.sh
#
# Checks:
# 1. Primary API health
# 2. Cluster registry (no duplicate hostnames, IPs are real LAN addresses)
# 3. Each worker's /health endpoint
# 4. GPU detection (nvidia-smi exits clean on nodes that report GPUs)
# 5. NVENC encode probe (5s of synthetic h264_nvenc → /tmp)
# 6. Blackmagic device enumeration
#
# Exit 0 = all pass, 1 = any failure. Failures are logged inline.
# =============================================================================
set -uo pipefail
MAM_API_URL="${MAM_API_URL:-}"
AUTH_TOKEN="${AUTH_TOKEN:-}"
if [[ -z "$MAM_API_URL" ]]; then
echo "✗ MAM_API_URL is required" >&2
exit 1
fi
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
PASS=0; FAIL=0
pass() { echo -e "${GRN}${NC} $*"; PASS=$((PASS+1)); }
fail() { echo -e "${RED}${NC} $*"; FAIL=$((FAIL+1)); }
note() { echo -e "${CYN}${NC} $*"; }
warn() { echo -e "${YEL} !${NC} $*"; }
api() {
local method="${1:-GET}"; shift
local path="$1"; shift
local args=(-sS -X "$method" -H 'Content-Type: application/json')
[[ -n "$AUTH_TOKEN" ]] && args+=(-H "Authorization: Bearer $AUTH_TOKEN")
curl "${args[@]}" "$@" "${MAM_API_URL}${path}"
}
echo -e "${BLD}${CYN}Wild Dragon — Cluster Smoke Test${NC}"
echo -e "Primary: $MAM_API_URL"
echo ""
# ── 1. Primary API health ───────────────────────────────────────────────
echo -e "${BLD}1. Primary API health${NC}"
if api GET /health | grep -q '"status":"ok"'; then
pass "primary /health responds"
else
fail "primary /health did not return ok"
fi
echo ""
# ── 2. Cluster registry ─────────────────────────────────────────────────
echo -e "${BLD}2. Cluster registry${NC}"
NODES_JSON=$(api GET /api/v1/cluster || echo '[]')
TOTAL=$(echo "$NODES_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
note "$TOTAL nodes registered"
if [[ "$TOTAL" -gt 0 ]]; then
# No duplicate hostnames
DUP=$(echo "$NODES_JSON" | python3 -c '
import sys, json
nodes = json.load(sys.stdin)
seen = {}
dups = []
for n in nodes:
h = n.get('hostname')
if h in seen: dups.append(h)
seen[h] = True
print(",".join(sorted(set(dups))))' 2>/dev/null)
if [[ -z "$DUP" ]]; then
pass "no duplicate hostnames"
else
fail "duplicate hostnames: $DUP — run migration 007"
fi
# No private docker IPs (172.16.0.0/12 reserved for docker bridges)
BAD_IPS=""
while IFS=$'\t' read -r host ip; do
[[ -z "$ip" ]] && continue
first="${ip%%.*}"; rest="${ip#*.}"; second="${rest%%.*}"
if [[ "$first" == "172" && "$second" == "17" ]]; then
BAD_IPS+="${host}=${ip},"
fi
done < <(echo "$NODES_JSON" | jq -r '.[] | [.hostname, (.ip_address // "")] | @tsv')
BAD_IPS="${BAD_IPS%,}"
if [[ -z "$BAD_IPS" ]]; then
pass "all node IPs are real LAN addresses"
else
fail "nodes still reporting docker bridge IPs: $BAD_IPS"
warn " → set NODE_IP in .env.worker and restart the node-agent"
fi
# All nodes recently seen
STALE=$(echo "$NODES_JSON" | python3 -c '
import sys, json
nodes = json.load(sys.stdin)
stale = [n["hostname"] for n in nodes if float(n.get("stale_seconds") or 9999) > 120]
print(",".join(stale))' 2>/dev/null)
if [[ -z "$STALE" ]]; then
pass "all nodes heartbeated within 2 min"
else
warn "stale nodes (>2 min since heartbeat): $STALE"
fi
fi
echo ""
# ── 3. Per-node /health probes ──────────────────────────────────────────
echo -e "${BLD}3. Worker agent /health endpoints${NC}"
echo "$NODES_JSON" | python3 -c '
import sys, json
for n in json.load(sys.stdin):
if n.get("role") == "primary": continue
print(n["id"], n["hostname"], n.get("api_url") or "")
' 2>/dev/null | while read -r ID HOST URL; do
[[ -z "$URL" ]] && { warn "$HOST: no api_url registered"; continue; }
if curl -sf --max-time 4 "$URL/health" >/dev/null 2>&1; then
pass "$HOST ($URL/health)"
else
fail "$HOST agent unreachable at $URL/health"
fi
done
echo ""
# ── 4. Local GPU + NVENC probe (when run on a GPU node) ─────────────────
echo -e "${BLD}4. Local GPU + NVENC${NC}"
if command -v nvidia-smi >/dev/null 2>&1; then
GPU_COUNT=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | wc -l)
if [[ "$GPU_COUNT" -gt 0 ]]; then
pass "$GPU_COUNT NVIDIA GPU(s) visible to host"
if command -v ffmpeg >/dev/null 2>&1; then
if ffmpeg -hide_banner -loglevel error \
-f lavfi -i testsrc=duration=5:size=1280x720:rate=30 \
-c:v h264_nvenc -preset p1 -b:v 4M \
-t 5 -f null - 2>/tmp/wd-nvenc.log; then
pass "NVENC encode test succeeded"
else
fail "NVENC encode failed — see /tmp/wd-nvenc.log"
fi
else
warn "ffmpeg not installed locally — skipping NVENC encode test"
fi
else
warn "nvidia-smi found but reports 0 GPUs"
fi
else
warn "nvidia-smi not present (not a GPU node)"
fi
echo ""
# ── 5. Blackmagic device enumeration ────────────────────────────────────
echo -e "${BLD}5. Blackmagic devices (cluster-wide)${NC}"
BMD_JSON=$(api GET /api/v1/cluster/devices/blackmagic || echo '[]')
BMD_COUNT=$(echo "$BMD_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
if [[ "$BMD_COUNT" -gt 0 ]]; then
pass "$BMD_COUNT DeckLink port(s) registered"
echo "$BMD_JSON" | jq -r '.[] | " \(.hostname) port=\(.index) model=\(.model // "unknown") online=\(.online)"'
else
warn "no DeckLink devices reported by any node"
fi
echo ""
# ── 6. Local Blackmagic device files ────────────────────────────────────
echo -e "${BLD}6. Local /dev/blackmagic${NC}"
if [[ -d /dev/blackmagic ]]; then
ls /dev/blackmagic/ | sed 's/^/ /'
pass "$(ls /dev/blackmagic/ | wc -l) device node(s) under /dev/blackmagic"
else
warn "no /dev/blackmagic on this machine"
fi
echo ""
# ── Summary ─────────────────────────────────────────────────────────────
echo -e "${BLD}Summary:${NC} ${GRN}$PASS pass${NC} ${RED}$FAIL fail${NC}"
[[ "$FAIL" -gt 0 ]] && exit 1 || exit 0

33
docker-compose.gpu.yml Normal file
View file

@ -0,0 +1,33 @@
# Wild Dragon MAM — GPU overlay
# Apply on top of docker-compose.yml on nodes with NVIDIA GPUs.
#
# Prerequisites: NVIDIA Container Toolkit installed on the host.
# Install: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/
#
# Usage (core MAM node with GPUs):
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
#
# Usage (worker node with GPUs):
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
#
# This overlay:
# - Rebuilds worker from Dockerfile.gpu (CUDA base + ffmpeg NVENC)
# - Passes all NVIDIA GPUs into the worker container
# - Sets NVENC_ENABLED=true so the worker prioritises h264_nvenc/hevc_nvenc
services:
worker:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
NVENC_ENABLED: "true"
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility

130
docker-compose.worker.yml Normal file
View file

@ -0,0 +1,130 @@
# Wild Dragon MAM — Worker Node Stack
# ─────────────────────────────────────
# Deploy on any machine you want to join the cluster as a worker.
# The primary stack (mam-api, db, redis) continues running on TrueNAS.
#
# Required env vars (set in .env.worker or export before running):
# MAM_API_URL URL of the primary MAM API e.g. http://10.0.0.25:47432
# NODE_TOKEN Bearer token from the primary's Tokens page
# NODE_IP Host LAN IP to report (set by onboard-node.sh)
#
# Optional hardware overrides (if Docker can't see /dev directly):
# GPU_COUNT Number of NVIDIA GPUs on this node (default: auto-detect from /dev/nvidia*)
# BMD_COUNT Number of Blackmagic DeckLink cards (default: auto-detect from /dev/blackmagic/)
# BMD_MODEL Marketed card name (e.g. "DeckLink Duo 2") — drives the port-diagram UI
#
# 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:
# (default) node-agent only — cluster visibility + hardware heartbeat
# --profile worker + CPU/GPU job worker (proxy generation, transcoding)
# --profile capture + SDI capture service (requires Blackmagic DeckLink card)
#
# To enable GPU transcoding, also apply docker-compose.gpu.yml:
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
#
# NOTE: The node-agent mounts /var/run/docker.sock to spawn on-demand SDI
# capture sidecars when the primary mam-api routes a recorder to this node.
# Build the capture image before first use:
# docker compose -f docker-compose.worker.yml build capture
services:
# node-agent runs in host network mode so it can see the real host
# interfaces, GPU devices and DeckLink cards without bridging tricks.
# The reported IP / hostname will be the host's, not the container's.
node-agent:
build: ./services/node-agent
restart: unless-stopped
network_mode: host
environment:
MAM_API_URL: ${MAM_API_URL}
NODE_TOKEN: ${NODE_TOKEN:-}
NODE_ROLE: ${NODE_ROLE:-worker}
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}
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
worker:
build: ./services/worker
profiles: [worker]
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}
NVENC_ENABLED: ${NVENC_ENABLED:-false}
networks:
- wild-dragon-worker
# SDI capture service — only start on nodes with Blackmagic DeckLink cards
# Set BMD_DEVICE_0 in .env.worker to the actual device path, e.g. /dev/blackmagic/dv0
capture:
build: ./services/capture
profiles: [capture]
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}
CAPTURE_PORT: 3001
devices:
- ${BMD_DEVICE_0:-/dev/blackmagic/dv0}:/dev/blackmagic/dv0
- ${BMD_DEVICE_1:-/dev/blackmagic/dv1}:/dev/blackmagic/dv1
ports:
- "${CAPTURE_PORT:-7437}:3001"
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

@ -5,6 +5,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${PORT_DB:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
@ -18,6 +20,8 @@ services:
queue:
image: redis:7-alpine
ports:
- "${PORT_REDIS:-6379}:6379"
volumes:
- redis_data:/data
networks:
@ -35,6 +39,13 @@ services:
volumes:
- /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
- /run/systemd:/run/systemd
- /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
@ -45,7 +56,21 @@ 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:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
networks:
- wild-dragon
@ -67,11 +92,21 @@ services:
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /run/systemd:/run/systemd
networks:
- wild-dragon
worker:
build: ./services/worker
# ── 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
depends_on:
- queue
- db
@ -83,6 +118,60 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
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
@ -92,18 +181,21 @@ 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
editor:
build: ./services/editor
depends_on:
- mam-api
ports:
- "${PORT_EDITOR:-47435}:80"
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
volumes:
postgres_data:

186
docs/DESCRIPTION.md Normal file
View file

@ -0,0 +1,186 @@
# 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

@ -0,0 +1,99 @@
# Growing Files + Premiere Panel — Test Plan
A local SMB landing zone for capture so Premiere can edit the master while
it is still recording. The promotion worker uploads the finalized file to S3
and the panel relinks Premiere to the hi-res original.
## Cluster state (deployed 2026-05-22)
- TrueNAS dataset: `NVME/MAM-growing` (LZ4, 0777)
- TrueNAS SMB share: `mam-growing``/mnt/NVME/MAM-growing`
- Host symlink for docker compose: `/mnt/NVME/MAM/wild-dragon-growing` → the dataset
- mam-api + worker containers mount it at `/growing`
- Settings (live): `growing_enabled=true`, `growing_smb_url=smb://10.0.0.25/mam-growing`
## Capture flow (when growing_enabled=true)
1. Recorder starts. mam-api spawns a capture sidecar with `GROWING_ENABLED=true`
and binds `/mnt/NVME/MAM/wild-dragon-growing:/growing`.
2. FFmpeg writes the hi-res master directly to
`/growing/<projectId>/<clipName>.<ext>` (no S3 stream).
3. The HLS tee continues to publish `/live/<assetId>/index.m3u8`, so the
Recorders + Monitors pages get a real video preview.
4. On stop — or when the file's mtime is idle for
`growing_promote_after_seconds` — the promotion worker:
- uploads the local file to S3 at `projects/<projectId>/masters/<clipName>.<ext>`
- queues a proxy job
- flips the asset to `status=ready`
- deletes the local copy.
## 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
```
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:
```
# macOS
defaults write com.adobe.CSXS.11 PlayerDebugMode 1
# Windows
reg add "HKCU\Software\Adobe\CSXS.11" /v PlayerDebugMode /t REG_SZ /d 1 /f
```
Mount the SMB share once at OS level: `smb://10.0.0.25/mam-growing`.
In Premiere: Window → Extensions → Wild Dragon MAM.
## Test the live → finalized swap
1. Start a recorder (Ingest → Recorders → Record).
2. The Recorder row's preview becomes a real HLS `<video>` element.
3. In Premiere, with the growing asset selected (status=live), click
**Mount Live**. The panel calls `GET /api/v1/assets/:id/live-path`,
resolves the SMB UNC path, and `app.project.importFiles()` it. Premiere
imports the still-growing MOV.
4. Stop the recorder. After `growing_promote_after_seconds` of mtime
inactivity, the promotion worker uploads to S3 and flips status.
5. The panel polls every 5 s. When it sees `status=ready` it surfaces
**Relink to Hi-Res** — clicking that downloads the finalized hi-res
and calls `ProjectItem.changeMediaPath()` to relink in place. Timeline
cuts are preserved.
## Knobs (Settings → Growing files (SMB))
- `growing_enabled` — master switch
- `growing_path` — container mount path (default `/growing`)
- `growing_smb_url` — what the Premiere panel hands to the editor
- `growing_promote_after_seconds` — idle threshold for promotion
## What's NOT yet here
- Auth on the SMB share — currently passwordless. Add Samba auth via
`midclt call sharing.smb.update` and put creds in the editor's keychain
before exposing this beyond the LAN.
- Concurrent S3 backup of the growing file. Today's MVP writes to SMB only;
S3 happens at promotion. If you need belt-and-suspenders, add `-f tee` in
capture-manager to fan out to both destinations.
- Cleanup for stranded files (e.g. recorder crashes mid-capture). The idle
threshold will eventually promote them, but a stale-file sweeper would be
more graceful.

116
docs/WORK_LOG_2026_05.md Normal file
View file

@ -0,0 +1,116 @@
# 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

@ -0,0 +1,197 @@
# 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

@ -0,0 +1,57 @@
# Cluster Hardening + Codec Settings Revamp
> Status: **mostly shipped 2026-05-21**. One follow-up remains: the recorders.html UI rewrite. See "Pending" below.
## Goal
Four user-driven asks from 2026-05-20:
1. Fix cluster page: workers were registering with docker bridge IPs, and three duplicate "zampp2" rows kept appearing.
2. Expand recorder codec settings: per-recorder control over bitrate, framerate, audio channels, container format.
3. Better DeckLink port picker: "BM1/BM2" dropdown was unusable -- diagram the card so operators pick a port visually.
4. Validate the cluster end-to-end now that GPUs are in place.
## What shipped (commit list)
| Commit | Area | Summary |
|---|---|---|
| `a39c983` | mam-api | Migration 007 -- dedupe `cluster_nodes` rows + unique index on `hostname`. |
| `049beb8` | mam-api | Migration 008 -- expanded codec columns on `recorders` (video/audio bitrate, framerate, audio channels, container, plus `node_id` / `device_index` pinning). |
| `3b4af6e` | node-agent | Prefer `NODE_IP` env override; skip docker bridge / veth / cni interfaces when auto-detecting. Version bumped to 1.1.0. |
| `0efef0d` | mam-api | `routes/cluster.js`: `pickIp()` fallback to request source IP. New `GET /api/v1/cluster/devices/blackmagic` flattens every node's DeckLink capabilities. |
| `40a66ba` | compose | `docker-compose.worker.yml`: `network_mode: host` for node-agent so it inherits host hostname + LAN IP. |
| `0ebb3cf` | deploy | `onboard-node.sh`: auto-detect host LAN IP and write `NODE_IP` + `BMD_MODEL` to `.env.worker`. |
| `f4a83ee` | capture | `capture-manager.js`: dynamic ffmpeg args. Exports `VIDEO_CODECS`, `AUDIO_CODECS`, `CONTAINER_FMT`, `CONTAINER_EXT`. |
| `485af25` | capture | `index.js` bootstrap forwards every codec env var to `captureManager.start()`. |
| `4c65753` | mam-api | `routes/recorders.js`: full codec field whitelist; `/start` passes settings to the capture sidecar. |
| `d39f86d` | web-ui | `services/web-ui/public/js/bmd-card.js` -- SVG renderer for DeckLink port selection. Models: Duo 2, Quad 2, Mini Recorder 4K, Mini Monitor 4K, UltraStudio 4K Mini. |
| `8aa3783` | deploy | `deploy/test-cluster.sh` cluster smoke test. |
| `4a3a672` | cluster | `mam-api` self-heartbeat reads `NODE_HOSTNAME` (otherwise every restart spawns a new primary row). Smoke test rewritten with `jq` after Python f-strings were found to silently false-pass the docker-bridge check. Bridge alarm narrowed to 172.17.x since this LAN occupies 172.18.0.0/16. |
## Verified cluster state (post-deploy, 2026-05-21)
```
$ MAM_API_URL=http://localhost:47432 bash deploy/test-cluster.sh
6 pass 0 fail
```
Two nodes registered, no duplicate hostnames, real LAN IPs (zampp1=172.18.91.216 primary, zampp2=172.18.91.217 worker), fresh heartbeats, 3 NVIDIA GPUs visible on zampp1, DeckLink Duo 2 reporting all 4 ports on zampp2.
## Deploy state
- **zampp1**: at `4a3a672`, rebuilt `mam-api`/`web-ui`/`worker`/`capture`, migrations 007+008 applied at startup. `.env` has `NODE_HOSTNAME=zampp1`, `NODE_IP=172.18.91.216`.
- **zampp2**: at `4a3a672`, rebuilt `node-agent` + `worker`. `.env` has `NODE_IP=172.18.91.217`, `BMD_COUNT=4`, `BMD_MODEL="DeckLink Duo 2"`, `BMD_DEVICE_0..3` populated.
- **Forgejo PAT** is at `/root/.git-credentials` on zampp1 (mode 600). Pushes from zampp1 need `HOME=/root`.
## LAN topology gotcha
The user's LAN is **172.18.91.0/24** -- inside Docker's reserved 172.16.0.0/12 range. Any heuristic that flags all of 172.16-172.31 as "docker bridge" will produce false positives. The smoke test now alarms only on 172.17.x (default docker0). The server-side `pickIp()` in `routes/cluster.js` has the same vulnerability but the node-agent's `NODE_IP` env-var override masks it in practice.
## Pending
- [ ] **`services/web-ui/public/recorders.html` rewrite.** The supporting pieces are in `main` but the HTML wiring was lost to a context-compaction event mid-session. Required UI:
- Tabbed codec settings (Video / Audio / Container) for both master and proxy.
- SDI source picker: node dropdown + inline `BMDCards.render(...)` SVG with click-to-select.
- Load BMD card data from `GET /api/v1/cluster/devices/blackmagic`.
- `<script src="js/bmd-card.js?v=1"></script>` in the head.
- SVG styles (`.bmd-card-svg`, `.bmd-port-ring`, `.bmd-port-group.is-selected`, ...) inlined or split into a CSS file.
- [ ] **Visual polish pass** with flyonui MCP -- the user noted the current UI "still looks AI-designed". Should happen AFTER the recorders.html rewrite.
## How to pick this up
1. `cd /opt/wild-dragon && git pull` on zampp1 (or zampp2).
2. Read this file end-to-end. Then `services/web-ui/public/js/bmd-card.js` (top JSDoc explains the API) and `services/capture/src/capture-manager.js` (codec catalogs).
3. Inspect `recorders.html` -- it still has the pre-revamp "BM1/BM2" dropdown and flat codec fields. Compare against the `recorders` table columns in `008-codec-settings.sql` for the full field set the UI should drive.
4. Iterate against a live deployment: `bash deploy/test-cluster.sh` for regression check, plus the actual `/recorders.html` page in a browser (web-ui on port 8080, mam-api on 47432).
5. Commit through Forgejo MCP if the diff is small; otherwise push from zampp1 (see Deploy state above for creds location). **Cloudflare WAF blocks large MCP uploads** (the blocked domain is `anthropic.com`, not Forgejo) -- pushing from a host with creds is faster for anything over ~3 KB.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,502 @@
# UI Shell Rework — Wave 2 (Low-risk page migrations) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or executing-plans. Steps use `- [ ]` checkboxes.
**Goal:** Migrate the 6 lowest-risk shell pages (login, home, settings, tokens, users, containers) from `css/common.css` + bespoke per-page CSS to the new `/dist/app.css` primitive bundle from wave 1. Each page goes from "old look" to "new look" with the same functionality. Also fold in the deferred token cleanups from the wave-1 code review.
**Architecture:** Each page migration is a self-contained markup rewrite. Pattern: swap the `<link>` to `/dist/app.css`, replace the sidebar + topbar markup with `wd-*` primitives, restyle page-specific content with `wd-card-asset` / `wd-card-op` / `wd-list-row` / `wd-form-*` / `wd-btn` / etc. Preserve every existing `<script>` and `id` so JS keeps working. Deploy after each page; check.
**Tech Stack:** Tailwind+flyon-ui bundle from wave 1 (already live), nginx static, no JS changes expected.
**Reference spec:** `docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md`
**Wave 1 plan:** `docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md`
---
## File structure
**Files this wave modifies:**
```
services/web-ui/
├── public/
│ ├── login.html (REWRITE: small, no sidebar, just form)
│ ├── home.html (REWRITE: hero + stat tiles, has sidebar)
│ ├── settings.html (REWRITE: tabbed settings forms, has sidebar)
│ ├── tokens.html (REWRITE: list of tokens + create panel, has sidebar)
│ ├── users.html (REWRITE: user list + edit slide-panel, has sidebar)
│ └── containers.html (REWRITE: docker container list + logs, has sidebar)
└── src/css/components/
└── tokens.css (MODIFY: add deferred token cleanups)
```
**Files this wave does NOT touch:** the other 9 pages (index, projects, upload, jobs, api-tokens, recorders, cluster, capture, edit, editor, player). They're wave 3 / 4 / excluded.
---
## Tasks
### Task 0: Fold deferred token cleanups into tokens.css
Address items 1, 2, 4, 6 from the wave-1 code review BEFORE the page migrations multiply duplication of raw oklch values.
**Files:**
- Modify: `services/web-ui/src/css/components/tokens.css`
- Modify: `services/web-ui/src/css/components/button.css` (use new tokens)
- Modify: `services/web-ui/src/css/components/card-operational.css` (use new tokens)
- Modify: `services/web-ui/src/css/components/sidebar.css`, `topbar.css`, `slide-panel.css`, `card-asset.css`, `form-controls.css`, `field-group.css`, `list-row.css`, `toast.css` (use shared --ease / --dur tokens)
- [ ] **Step 1: Extend tokens.css with the missing tokens**
Append to `services/web-ui/src/css/components/tokens.css` inside `:root`:
```css
/* Hover-darker variants of accent + signals — promoted from
* inline oklch() arithmetic that was duplicated across button.css
* and card-operational.css */
--accent-hover: oklch(52% 0.20 266);
--accent-bright: oklch(70% 0.18 266);
--signal-bad-hover: oklch(68% 0.22 25);
--signal-good-hover: oklch(74% 0.18 148);
--signal-warn-hover: oklch(84% 0.16 90);
/* Pure-black-ish tinted toward brand hue for thumbnails & overlays.
* Numerically still ~black but the hue channel is set so future
* derivations stay on-brand. */
--thumb-black: oklch(0% 0 266);
--overlay: oklch(8% 0.010 266 / 0.65);
--shadow: oklch(0% 0 266 / 0.5);
/* Motion + ease tokens — promoted from raw cubic-bezier strings
* that appeared in 8 of 12 primitive files */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--dur-fast: 120ms;
--dur-normal: 180ms;
--dur-slide: 240ms;
/* Z layers — promoted from sidebar/topbar where 30 was hard-coded */
--z-topbar: 30;
```
- [ ] **Step 2: Find-and-replace raw oklch hover values across primitives**
For each of these files, replace the literal oklch string with the new token. Use `sed -i` for the substitutions, but verify each file afterward.
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
# button.css
sed -i 's|background: oklch(52% 0.20 266);|background: var(--accent-hover);|' button.css
sed -i 's|background: oklch(68% 0.22 25);|background: var(--signal-bad-hover);|' button.css
# card-operational.css — gradient stop in signal-strip-fill
sed -i 's|oklch(70% 0.18 266)|var(--accent-bright)|' card-operational.css
# card-asset.css — pure-black thumb background
sed -i 's|background: oklch(0% 0 0);|background: var(--thumb-black);|' card-asset.css
# slide-panel.css — overlay color
sed -i 's|oklch(8% 0.010 266 / 0.65)|var(--overlay)|' slide-panel.css
# toast.css — shadow
sed -i 's|oklch(0% 0 0 / 0.7)|var(--shadow)|' toast.css
```
- [ ] **Step 3: Replace raw cubic-bezier strings with --ease + --dur tokens**
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
# Replace exact "120ms cubic-bezier(0.25, 1, 0.5, 1)" with the tokens
for f in sidebar.css topbar.css slide-panel.css card-asset.css card-operational.css form-controls.css field-group.css list-row.css button.css; do
sed -i 's|120ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-fast) var(--ease-out-quart)|g' "$f"
done
# slide-panel slide-in (240ms ease-out-expo)
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' slide-panel.css
# Tab indicator
sed -i 's|240ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-slide) var(--ease-out-quart)|' slide-panel.css
sed -i 's|200ms cubic-bezier(0\.25, 1, 0\.5, 1)|200ms var(--ease-out-quart)|g' form-controls.css
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' card-operational.css
```
- [ ] **Step 4: Replace hard-coded z-index 30 with --z-topbar**
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
sed -i 's|z-index: 30;|z-index: var(--z-topbar);|' topbar.css
```
- [ ] **Step 5: Rebuild + verify primitives still ship correctly**
```bash
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
docker exec wild-dragon-web-ui-1 grep -c '.wd-' /usr/share/nginx/html/dist/app.css
# Expect: same large number (~116+) — no rules dropped
docker exec wild-dragon-web-ui-1 grep -c '\-\-accent-hover\|\-\-ease-out-quart\|\-\-z-topbar' /usr/share/nginx/html/dist/app.css
# Expect: at least 3 hits (tokens now defined + referenced)
```
- [ ] **Step 6: Visual regression check on smoke page**
```bash
curl -sk -o /dev/null -w 'smoke=%{http_code}/%{size_download}\n' http://localhost:47434/_primitives-smoke.html
# Expect: HTTP 200, ~12 KB (unchanged from wave 1)
```
Manually load the smoke page in a browser; everything should look identical to wave 1. If anything changed visually, the sed substitutions introduced a regression.
- [ ] **Step 7: Commit + push**
```bash
cd /opt/wild-dragon
HOME=/root git add services/web-ui/src/css/components/
HOME=/root git diff --cached --stat
HOME=/root git -c user.email=zgaetano@wilddragon.net -c user.name='Zac Gaetano' commit -m 'web-ui: token cleanups from wave-1 code review
- Promote --accent-hover, --signal-bad-hover, --signal-good-hover,
--signal-warn-hover, --accent-bright tokens (were duplicated raw
oklch arithmetic in button.css / card-operational.css)
- Promote --thumb-black, --overlay, --shadow tokens (tinted toward
brand hue 266 so future derivations stay on-brand)
- Promote --ease-out-quart, --ease-out-expo, --dur-fast/normal/slide
tokens (cubic-bezier strings appeared in 8 of 12 primitive files)
- Promote --z-topbar (was hard-coded 30 in topbar.css while every
other layer was tokenized)
- Replace all usages across the 12 primitive files via sed.
Bundle byte count unchanged (~138 KB); visual regression on smoke
page = zero. Code-review concerns from wave 1 now resolved before
wave 2 page migrations begin.'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 1: Migrate login.html
Smallest page. No sidebar, no topbar — just a centered card with email/password. Migrating first because if it breaks nothing else does.
**Files:**
- Modify: `services/web-ui/public/login.html`
- [ ] **Step 1: Read the current page to see what's there**
```bash
cd /opt/wild-dragon/services/web-ui/public
cat login.html | head -80
```
Note: form ids, input names, any inline JS handlers. Preserve all of them.
- [ ] **Step 2: Write the new login.html**
The new structure:
- `<link rel="stylesheet" href="/dist/app.css">` instead of the old `common.css`
- Centered `<main>` with a single `.wd-card-op`-shaped panel (operational card primitive, sized small)
- Inside: brand logo + "Z-AMPP" wordmark at top, then `<form>` with two `.wd-form-group` (email + password), then `.wd-btn.wd-btn--primary.wd-btn--md` submit
- Keep every existing `id`, `name`, `type`, and `<script>` tag from the old file
- If there's an "error message" div, replace its class with `.wd-toast.wd-toast--error` (inline, not floating)
Replace the entire `<head>` and `<body>` with the new shell. JS at the bottom stays as-is.
- [ ] **Step 3: Deploy on zampp1 (no Docker rebuild needed — HTML is static)**
```bash
# Actually nginx serves from the image's filesystem, not the host's
# /opt/wild-dragon/services/web-ui/public/. So we DO need a rebuild.
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'login=%{http_code}/%{size_download}\n' http://localhost:47434/login.html
# Confirm new bundle is referenced
curl -sk http://localhost:47434/login.html | grep -E 'dist/app.css|common.css'
# Expect: dist/app.css present, common.css absent
```
- [ ] **Step 4: Visual + functional check**
Open `http://172.18.91.216:47434/login.html` in a browser. Verify:
- Page renders with new brand styling
- Email + password fields look like the wd-input primitive
- Submit button looks like wd-btn--primary
- Logging in still actually works (POST to /api/v1/auth/login)
- [ ] **Step 5: Commit + push**
```bash
HOME=/root git add services/web-ui/public/login.html
HOME=/root git commit -m 'web-ui(wave 2): migrate login.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 2: Migrate home.html
Has sidebar + topbar + dashboard stat tiles. The first page that exercises the full shell.
**Files:**
- Modify: `services/web-ui/public/home.html`
- [ ] **Step 1: Read the current page**
```bash
cd /opt/wild-dragon/services/web-ui/public
wc -l home.html
head -80 home.html
```
Identify: page title, what's in the topbar right side, the stat tile structure, any chart libraries, and the bottom `<script>` blocks. Preserve all script references and JS state.
- [ ] **Step 2: Migrate the markup**
The migration recipe for every shell page:
1. **`<head>`**: replace `<link rel=stylesheet href=css/common.css>` with `<link rel=stylesheet href=/dist/app.css>`. Keep favicon, viewport meta.
2. **`<body>` root**: wrap in `<div class="wd-shell">` (style inline: `display:flex;min-height:100vh`).
3. **Sidebar**: copy verbatim from the smoke page's `<nav class="wd-sidebar">` block. Mark the active nav item with `is-active` on Home.
4. **Right column**: `<div style="flex:1;display:flex;flex-direction:column;">`
5. **Topbar**: `<header class="wd-topbar">` with breadcrumb in `.wd-topbar-left` containing just "Home", any existing right-side button as `.wd-btn.wd-btn--primary.wd-btn--sm`.
6. **Main content**: `<main style="padding:20px 20px 32px;">`
7. **Stat tiles**: replace with `.wd-card-op-grid` containing `.wd-card-op` (small, content-only — no footer needed if there's no action).
8. **Auth-guard script** at the bottom — stays exactly as-is.
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'home=%{http_code}/%{size_download}\n' http://localhost:47434/home.html
curl -sk http://localhost:47434/home.html | grep -c 'wd-sidebar\|wd-topbar\|wd-card'
# Expect: 8+ matches
```
Load `http://172.18.91.216:47434/home.html` in a browser. Sidebar should be the new one, breadcrumb shows "Home", stat tiles render as operational cards.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/home.html
HOME=/root git commit -m 'web-ui(wave 2): migrate home.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 3: Migrate settings.html
System settings form. Lots of form-groups, possibly tabbed.
**Files:**
- Modify: `services/web-ui/public/settings.html`
- [ ] **Step 1: Read current page + identify all form sections**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E '<h[12]|form-group|form-section-label' settings.html | head -30
```
- [ ] **Step 2: Migrate using the standard recipe**
Same recipe as task 2, except for the form content:
- Replace each form section with a `.wd-field-group` (header + body, no tabs unless the section is genuinely tabbed)
- Replace every `<input>` with `class="wd-input"`, every `<select>` with `class="wd-select"`, every `<label>` with `class="wd-label"`
- Replace every `<button>` with `class="wd-btn wd-btn--primary wd-btn--md"` (or `--secondary` / `--ghost` / `--danger` as appropriate)
- Wrap rows of inputs in `.wd-form-row`
- Preserve every `id`, `name`, `type`, and JS handler
Set `.wd-nav-item.is-active` on Settings.
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'settings=%{http_code}/%{size_download}\n' http://localhost:47434/settings.html
```
Load in browser. Verify the form actually saves (test by changing one value and clicking save).
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/settings.html
HOME=/root git commit -m 'web-ui(wave 2): migrate settings.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 4: Migrate tokens.html
Lists API tokens, allows creation of new ones with a slide-panel. First page that exercises the slide-panel primitive in the migrated context.
**Files:**
- Modify: `services/web-ui/public/tokens.html`
- [ ] **Step 1: Read + identify the slide-panel structure**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E 'slide-panel|slide-overlay|wd-list-row' tokens.html | head -20
```
- [ ] **Step 2: Migrate**
- Standard shell recipe (sidebar with `is-active` on Tokens, topbar with "Tokens" breadcrumb and "New token" primary button)
- Token list → `.wd-list` containing `.wd-list-row` for each token: name (cell--name), created date (cell--meta), badge for scope, action buttons in cell--actions
- Create-token form moves into `.wd-slide-panel` (with overlay, header, body, footer pattern exactly as in the smoke page's field-group)
- Preserve every JS handler — especially the copy-to-clipboard one for the newly-generated token
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'tokens=%{http_code}/%{size_download}\n' http://localhost:47434/tokens.html
```
Load + verify: clicking "New token" opens the slide-panel (codec-clipping bug fix from wave 1 applies — body should scroll if it overflows), creating a token shows the copy-once display.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/tokens.html
HOME=/root git commit -m 'web-ui(wave 2): migrate tokens.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 5: Migrate users.html
User management. List + edit slide-panel. Pattern matches tokens.html closely.
**Files:**
- Modify: `services/web-ui/public/users.html`
- [ ] **Step 1: Read + identify**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E '<h[12]|wd-list|slide-panel' users.html | head
```
- [ ] **Step 2: Migrate using the tokens.html recipe**
Identical pattern to task 4: shell + list + slide-panel for create/edit. Mark Users active. Preserve every JS handler (role dropdown, password reset, etc.).
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'users=%{http_code}/%{size_download}\n' http://localhost:47434/users.html
```
Verify: list renders, edit panel opens, save works.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/users.html
HOME=/root git commit -m 'web-ui(wave 2): migrate users.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 6: Migrate containers.html
Docker container list. List rows with status badges + action buttons (logs / restart). No slide-panel (logs typically opens in a separate tab or inline).
**Files:**
- Modify: `services/web-ui/public/containers.html`
- [ ] **Step 1: Read + identify**
```bash
cd /opt/wild-dragon/services/web-ui/public
head -80 containers.html
```
- [ ] **Step 2: Migrate**
- Standard shell recipe (Containers active)
- Container list → `.wd-list` with `.wd-list-row` per container:
- cell--name: container name
- cell with image: cell--meta
- cell with status: `.wd-badge.wd-badge--good` (Up) / `.wd-badge--bad` (Down) / `.wd-badge--warn` (Restarting)
- cell--actions: ghost buttons for Logs / Restart / Stop
- Auto-refresh polling JS stays unchanged
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'containers=%{http_code}/%{size_download}\n' http://localhost:47434/containers.html
```
Load + verify: all containers visible, status badges color-coded, Logs button still opens logs.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/containers.html
HOME=/root git commit -m 'web-ui(wave 2): migrate containers.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 7: Wave-2 user QA gate
- [ ] **Step 1: Verify all 6 migrated pages serve correctly**
```bash
for p in login home settings tokens users containers; do
printf ' %s.html: HTTP=%s\n' "$p" "$(curl -sk -o /dev/null -w '%{http_code}' http://localhost:47434/$p.html)"
done
```
Expected: all 200.
- [ ] **Step 2: Verify all 6 pages reference /dist/app.css and NOT common.css**
```bash
for p in login home settings tokens users containers; do
CNT_NEW=$(curl -sk http://localhost:47434/$p.html | grep -c dist/app.css)
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
printf ' %s.html: new=%s old=%s\n' "$p" "$CNT_NEW" "$CNT_OLD"
done
```
Expected: new=1 old=0 for every page.
- [ ] **Step 3: Verify wave-3 / wave-4 pages are STILL on the old CSS (no accidental change)**
```bash
for p in index projects upload jobs api-tokens recorders cluster capture editor; do
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
printf ' %s.html: still-on-old=%s\n' "$p" "$CNT_OLD"
done
```
Expected: still-on-old=1 for every page (none of them migrated yet).
- [ ] **Step 4: User visual QA**
Stop. Ask user to load each of the 6 migrated pages and confirm the new look is correct, navigation still works, forms still save, lists still poll. If anything looks wrong, fix it before wave 3 starts.
---
## Self-review notes
- **Spec coverage**: Every page in the wave-2 list from the design spec is in the plan. Token cleanups from wave-1 review are folded in as task 0.
- **Placeholders**: none. Every step has the actual command / file change.
- **Type consistency**: every migrated page uses `.wd-shell` / `.wd-sidebar` / `.wd-topbar` / `.wd-nav-item.is-active` / `.wd-card-op` / `.wd-list` / `.wd-list-row` / `.wd-btn` / `.wd-input` / `.wd-select` / `.wd-label` / `.wd-form-row` / `.wd-field-group` — exact class names from the wave-1 bundle.
- **Open risk**: each page migration is a manual markup rewrite. The implementer subagent needs to actually read each existing page before rewriting, not work from the description alone, because each page has page-specific JS handlers that must be preserved verbatim.

View file

@ -0,0 +1,73 @@
# 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

@ -0,0 +1,198 @@
# UI Shell Rework — Design Spec
> Status: **design approved 2026-05-21**, awaiting user review of the spec before the implementation plan is written.
## Context
The Wild Dragon MAM web-ui currently ships 15 static HTML pages served by nginx, sharing a single hand-written `common.css`. The token system is already strong (oklch palette, brand hue 266, 5-step depth surfaces, semantic signal tokens, 4pt spacing). What does not work is the gap between tokens and execution: cards across pages have undifferentiated spacing, generic chrome, weak hierarchy, and identical shape regardless of role. The user feedback that prompted this work was "the UI still looks AI-designed."
The rework adopts flyon-ui (Tailwind plugin) as the component primitive layer, ports the oklch palette into a custom flyon-ui theme so brand identity is preserved, and rebuilds every page that uses the standard shell against the new primitives. The personality target is *quiet pro tool* — closer to Sony Media Cloud and DaVinci Resolve than to a consumer SaaS dashboard.
## Goals & non-goals
**Goals**
- A coherent visual system across every shell page (15 pages minus 3 excluded).
- Higher information density at every screen — closer to Sony / DaVinci than to today's spacing.
- Four distinct card families so the eye reads role from shape.
- A theme port that preserves brand hue 266 and the existing oklch palette under flyon-ui.
- Accessibility floor: WCAG AA contrast, full keyboard nav, reduced-motion honored.
**Non-goals**
- Mobile UX. Phones get an explicit "desktop only" splash. Tablet gets a collapsed icon-rail sidebar but no further accommodation.
- Replacing the brand color, the font stack, or the dark theme.
- Animations beyond functional state transitions (no celebrations, no page-fade, no sound design).
- Adding new pages or features. This is purely visual / structural.
- Rebuilding `edit.html`, `editor.html`, or `player.html` (deliberately excluded — see Rollout).
## Personality, scene & color strategy
- **Register:** product (app UI, design serves the product), not brand.
- **Theme scene sentence:** "MAM operator at a 27-inch monitor in a dim control room, scanning a grid of 100+ video assets at 2am while a live recording timer runs." Forces dark, low-chroma, tabular-numeric trust signals.
- **Color strategy:** restrained. Tinted neutrals (chroma 0.0100.015, hue 266) plus a single amber accent used in ≤10% of surfaces — for active states, recording indicators, primary CTAs, focus rings.
- **Anti-patterns explicitly banned:** glassmorphism as default, gradient text, side-stripe borders (>1px on the side of cards/rows/callouts), hero-metric SaaS template, identical card grids across roles, em dashes in copy. Cliché category-reflex check passes: this design lands as DAW / NLE / NLE-adjacent operational tool, not "dark blue observability dashboard."
## Architecture
### 1. Build system & theme port
The `services/web-ui` Docker image gains a Node build stage. During `docker build`, `tailwindcss --minify` runs once, scans `public/**/*.html` for class usage, and emits `public/dist/app.[hash].css`. The runtime stage stays nginx-static — no runtime Node, longer startup, or extra moving parts.
A `tailwind.config.js` at `services/web-ui/` defines a custom flyon-ui theme that maps the existing oklch palette into flyon-ui's color slots. Brand hue 266 is preserved; the 5-step depth surfaces become Tailwind's `bg-deep` / `bg-base` / `bg-panel` / `bg-surface` / `bg-raised` / `bg-hover` utility chain. Signal tokens (`signal-good` / `signal-bad` / `signal-warn` / `signal-idle`) map directly. Spacing scale uses Tailwind's default 4pt scale, which already matches the existing `--sp-*` tokens — utilities like `p-3` and `gap-4` replace `var(--sp-3)` and `gap: var(--sp-4)`.
Fonts (Inter + JetBrains Mono) move from Google CDN to self-hosted woff2 in `public/fonts/`. The four "legacy alias" entries in the current `:root` (`--status-amber`, `--status-amber-bg`, etc.) get cleaned up during the port.
The custom theme also disables flyon-ui utility classes for the banned patterns: no `glass-*`, no `gradient-text`, no card-shadow defaults.
### 2. Sidebar
- **Dimensions:** 200px wide (down from 220px). Items 28px tall (down from ~36px), 8px horizontal padding, 4px vertical.
- **Type:** Inter 13px / 500 for items. Section labels 10px / 600 / 0.14em tracked / uppercase / `text-tertiary`.
- **Header:** 18px dragon logo + Inter 13px / 600 / -0.01em "Z-AMPP" wordmark. Total header height 48px to align with topbar.
- **Active state:** `bg-surface` background + `text-primary` text + 4px leading accent dot (8px tall, vertically centered). No side-stripe border (banned). No accent background fill.
- **Hover:** `bg-hover` fade-in over 120ms ease-out. No transform.
- **IN DEV badge** (injected by `auth-guard.js`): retained, restyled as 9px / 700 / 0.12em tracked amber pill.
- **Footer user widget:** 28px round avatar, name + role stacked, logout button reveals on row hover.
### 3. Topbar
- **Dimensions:** 48px tall. Padding 16px left / 12px right. Bottom border `border-faint`.
- **Left:** breadcrumb pattern, not flat title. Inter 13px / 500 / `text-secondary` for ancestor crumbs, 13px / 600 / `text-primary` for current. 10px chevron separator in `text-tertiary` with 8px gutters.
- **Center:** page-scoped search input on pages that have searchable content (Library, Recorders, Projects, Jobs, Cluster). 360px wide, 28px tall, leading magnifier, monospace placeholder.
- **Right:** primary CTA rightmost (28px button with 12px leading icon), 1px vertical divider, then 28px-square icon-only ghost buttons for secondary actions (filter, sort, view-toggle).
- **Sticky:** `position: sticky; top: 0; z-index: 30` inside `.main`. Sidebar does not scroll separately; topbar stays visible while content scrolls.
### 4. Card families
Four distinct card shapes, each with one clear job. Same-shape repetition is banned.
**Asset card** — Library, Projects asset grid, Recorders recording cards
- 16:9 thumbnail, full-bleed. Duration chip bottom-right (JetBrains Mono 10px, `bg-deep` 70% opacity). Comment-count chip bottom-left (when >0). Selection checkbox top-left (only on hover or when any are selected). Version badge top-right when applicable.
- Metadata: filename (Inter 13px / 500), then `{author} · {date}` row (11px / `text-tertiary`, mono numerics).
- Role pill at bottom: full-width, light tint of role color, 10px / 600 / 0.08em tracked / uppercase. Dotted-border placeholder when unset.
- 1px `border-faint`, 6px radius, `bg-panel`. Hover: thumbnail +4% brightness, border lifts to `border`. No scale, no shadow.
**Operational card** — Recorders cards, Cluster nodes, Jobs queue
- Header / content / footer rows. Wider than tall (min 8:3 ratio). 14px padding, 10px row gap.
- Header: 16px name + status pill (semantic signal tokens).
- Content: role-specific. Recorder gets live preview + signal strip + timer; cluster node gets CPU/mem mini-bars; job gets progress strip.
- Footer: 1px top hairline, metadata-left + actions-right.
- Border 1px `border-faint`, becomes 1px `accent-border` when active (recording / online / running). Color change only — no glow, no shadow, no animation.
**Inline list row** — Containers, Users, Tokens, API tokens
- Not a card. Table row with extra breathing room. 44px tall, hairline divider (`border-faint`).
- Hover: `bg-hover` row tint. Selected: `bg-surface` tint + 4px leading accent dot (same indicator language as sidebar active state).
**Empty state**
- Centered 28px line icon (`text-tertiary`), 14px / 600 title, 13px body, primary action button. No card chrome — the empty state IS the page.
- Declarative copy. No exclamation points, no emojis.
### 5. Grids
- Asset grid: `repeat(auto-fill, minmax(220px, 1fr))` with 12px gap.
- Operational grid: `repeat(auto-fill, minmax(380px, 1fr))` with 14px gap.
- Page content padding: 20px sides, 16px top, 32px bottom.
### 6. Forms, slide-panels & inputs
**Slide-panel structure (the codec-clipping bug fix codified as a primitive):**
- 460px wide. `height: 100vh; display: flex; flex-direction: column; overflow: hidden`. Header `flex-shrink: 0`. Body `flex: 1; min-height: 0; overflow-y: auto`. Footer `flex-shrink: 0; bg-deep`.
- Header 52px, 18px padding, title + close button. Bottom border `border-faint`.
- Body 18px padding, `display: flex; flex-direction: column; gap: 16px`.
**Form primitives:**
- Label: 11px / 600 / 0.08em tracked / uppercase / `text-tertiary`.
- Input / select / textarea: 32px tall, 10px horizontal padding, 13px text, 1px `border` outline, 4px radius. Focus: `accent-border` outline + 2px `accent-subtle` ring.
- Form hint: 11px / `text-tertiary` / 1.5 line-height. JetBrains Mono code spans.
- Form row: `grid-template-columns: 1fr 1fr; gap: 14px`.
**Field-group (tabbed sections, generalized from the codec-block pattern):**
- Titled header strip (36px, `bg-surface`) + tab row (32px, `bg-deep`) + tab panels (14px padding).
- Active tab: 2px `accent` bottom border, text shifts from `text-tertiary` to `accent`. Tab switches are instant — no animation.
**Buttons:**
- Sizes: `sm` 28px / `md` 32px / `lg` 36px.
- Variants: `primary` (accent bg), `secondary` (`bg-surface` + border), `ghost` (transparent + secondary text, `bg-hover` on hover), `danger` (status-red bg).
- Leading icon: 12px svg, 6px gap. Disabled: 40% opacity. Active press: 60ms `opacity: 0.85`. No gradient, no shadow, no scale.
**Toggle:** 34×18, track `bg-hover``accent`, 200ms ease-out on dot only.
**Date / time inputs:** native `<input type="date">` styled to match the input primitive. No third-party picker library.
### 7. States, motion & feedback
**Loading:** skeleton blocks matched to content shape. Asset grid → 12 placeholder cards with 1.8s gradient shimmer (not opacity pulse). In-card actions get inline 12px ring spinner. In-button: label replaced by spinner, width preserved.
**Empty states:** fade in 240ms on first load; instant when user-initiated.
**Errors:**
- *Toast* (bottom-right, 320px): `bg-panel` + 1px `status-red` border + 4px `status-red` top strip. Auto-dismiss 4s success / 8s warning / manual error. Stack up to 3.
- *Inline*: red 11px text below offending field. No icon, no shake.
- *Page-level*: full-page card with icon + plain-English title + Retry + Get-help buttons. Never blocks the sidebar.
**Success:** "Recorder saved" toast. Affected card briefly tints (200ms `accent-subtle` background, fades back over 1.2s). One-time. No checkmark celebrations.
**Live / realtime (recording-in-progress):**
- Signal strip shimmer 1.8s ease-in-out (down from 2.4s linear).
- "LIVE" preview-stamp dot stutter pattern: bright 0.9s / dim 0.3s / bright 0.9s (broadcast tally light).
- Timer: 600 weight, `status-red` while recording. Already correct.
**Hover / focus:**
- All transitions 120ms ease-out on `border-color` and `background-color` only. Never on `width`, `height`, `transform`.
- Focus ring: 2px `accent-subtle` outline, 1px offset, `:focus-visible` only.
**Page transitions:** none. Click nav → page renders. Slide-panel keeps 240ms slide-in from right.
**Notifications:** no bell, no global status banner. Failures surface inline on affected pages.
### 8. Accessibility & responsive
**A11y floor:**
- WCAG AA contrast on every text/background pair. `text-tertiary` lightness bumped from 52% to 56% to clear AA cleanly.
- `:focus-visible` ring on every interactive element.
- Full keyboard nav. Slide-panel traps focus while open; Esc closes overlays.
- Every icon-only button gets `aria-label`. Toasts use `role="status" aria-live="polite"`.
- `prefers-reduced-motion: reduce` honored: kills shimmer / pulse / slide-in, state changes become instant.
**Responsive:**
- Desktop-first tool. *Fully supported* minimum viewport: 1280×800. Anything narrower is best-effort only.
- ≥1600px: standard layout, content max-width 1440px, centered.
- 12801599px: standard layout, no max-width cap.
- 7681279px (tablet, best-effort): sidebar collapses to a 56px icon-rail, breadcrumb truncates to last crumb. Functional but not polished.
- <768px (phone): explicit "Z-AMPP is desktop-only" splash. No fake mobile experience.
**Browser support:** Chromium latest 2 versions + Safari latest 2. Firefox best-effort. No IE / legacy Edge.
## Rollout
Four waves, ordered by blast radius. Each wave is its own commit / deploy / verify cycle on zampp1.
**Wave 1 — Foundation (zero user-visible change).** Build pipeline, Tailwind + flyon-ui config, theme port, primitive CSS components, shell markup migration. New primitives exist but no page uses them yet. *Validates the build system works end-to-end.*
**Wave 2 — Shell + low-risk pages.** `login.html`, `home.html`, `settings.html`, `tokens.html`, `users.html`, `containers.html`. New shell, new card / list patterns, new forms. Low risk: no live data, simple flows.
**Wave 3 — Content-heavy pages.** `index.html` (Library), `projects.html`, `upload.html`, `jobs.html`, `api-tokens.html`. Asset grid, project tree, upload queue, job queue.
**Wave 4 — Operational pages.** `recorders.html`, `cluster.html`, `capture.html`. Live data, HLS preview, signal polling, BMD picker, codec slide-panel. Done last so the primitives are battle-tested before they meet the most fragile pages.
**Excluded from rework:** `edit.html`, `editor.html` (in-development construction screen is the right treatment), `player.html` (standalone embed, no shell).
**Definition of done per page:** new shell + new primitives + AA contrast verified + keyboard-nav check + responsive at 1280/1440/1920 widths + no JS regressions (live-recording flow on recorders.html is the canary).
## Risks & mitigations
| Risk | Mitigation |
|---|---|
| Tailwind build pipeline introduction breaks docker build | Wave 1 ships the build *without* migrating any page. If build fails, we revert without losing functionality. |
| Theme port loses brand hue 266 character | Custom flyon-ui theme explicitly maps existing oklch tokens; QA on wave 1 includes side-by-side color comparison vs. current. |
| Recorders rewrite (just stabilized) gets re-touched in wave 4 | Wave 4 is last on purpose — primitives are battle-tested by then. The codec-tab pattern from the recent recorders rewrite is generalized into the `.field-group` primitive in wave 1, so wave 4's recorders rewrite is mostly markup migration, not pattern reinvention. |
| Density target is too aggressive — 13px text / 28px rows feel cramped on smaller monitors | Wave 1 ships density + AA verified at 1280×800. If feedback says cramped, bump base text to 14px in a single token change. |
| Page-level skeleton loaders are extra implementation work | Acceptable cost. Spinners-only would feel cheaper than the rest of the design. |
| Native `<input type="date">` looks inconsistent across Chromium / Safari | Acceptable. Inconsistency is small; bundle weight savings of avoiding a date-picker library is real. |
## Implementation plan handoff
Once this spec is approved by the user, the next step is invoking the `superpowers:writing-plans` skill to produce a wave-by-wave implementation plan with concrete commit / deploy steps. The plan will live at `docs/superpowers/plans/2026-05-21-ui-shell-rework-plan.md` and reference this spec.
## Open questions
None. All seven design sections were approved by the user (Zac) during brainstorming on 2026-05-21. No placeholder values remain in this spec.

View file

@ -0,0 +1,272 @@
# YouTube Importer — Design Spec
> Status: **design approved 2026-05-23**, awaiting user review of the spec before the implementation plan is written.
## Context
The Ingest group in Dragonflight today covers file Upload, Recorders (SRT/RTMP/SDI), Capture (DeckLink), Monitors, and Schedule. There is no path to bring in media that already lives on the public web. The frequent ask is: "I want to grab a YouTube link and have it become an asset in my project, with the same proxy/thumbnail pipeline as anything else." This spec adds a YouTube importer that mirrors the existing upload flow: paste a URL, pick a project, click Import, and the asset shows up in the Library once the worker is done.
The importer rides on the existing job pipeline. After the download lands in S3, the asset re-enters the same proxy → thumbnail → ready path as a regular upload, so there is no parallel "imported asset" lifecycle to maintain.
## Goals & non-goals
**Goals**
- Paste a public YouTube URL, end up with a `ready` asset in the chosen project.
- Reuse the existing `assets` table, S3 layout, BullMQ pipeline, and Jobs screen — no parallel state machine.
- Progress visible from both the import screen (queue rows) and the Jobs screen.
- Clear, actionable errors for the obvious failure modes (private, age-gated, removed, geo-blocked, network).
**Non-goals**
- Playlists, channels, or batch-paste of multiple URLs. Single URL per submission. (Easy to add later.)
- Cookies / login. Private, members-only, and age-gated videos are out of scope v1.
- Quality picker. Always grabs best MP4 (with M4A audio merge fallback).
- Non-YouTube sources (Vimeo, Twitch VODs, Dropbox links, etc.). The route is `/imports/youtube` precisely to leave room for siblings later.
- Auto-update of yt-dlp inside the running container. Updates land via image rebuild.
- Copyright enforcement. We surface a one-line "only import videos you have rights to use" note and stop there.
## Architecture
The importer threads through four existing layers:
```
[web-ui] YouTube screen ──POST /imports/youtube──▶ [mam-api]
assets row (status='ingesting')
jobs row (type='youtube_import')
BullMQ "import" queue
[worker]
yt-dlp download → S3 originals/
ffprobe metadata → assets row
status='processing'
BullMQ "proxy" queue ◀── existing path
proxy → thumbnail → ready
```
Once the worker hands off to the `proxy` queue, the asset is indistinguishable from one that came through Upload — same proxy worker, same thumbnail worker, same Library list.
## 1. UX
### Nav
A 6th child is added to the **Ingest** group in `shell.jsx`, between Upload and Recorders:
```js
{ id: "youtube", label: "YouTube", icon: "download" },
```
The `download` glyph already exists in `icons.jsx`. The matching `ingestChildren` array in `shell.jsx` and the crumbs map in `app.jsx` both gain `"youtube"`.
### Screen
A new `YouTubeImport` component lives in `screens-ingest.jsx` and is exported on `window` alongside `Upload`, `Recorders`, etc. It is registered as a route in `app.jsx`.
Layout — visually a sibling of the Upload screen:
- **Header**: title "YouTube", subtitle "Paste a link — we download and import the best available MP4."
- **Project selector**: same `select` element as Upload's, pre-selected to the first project.
- **URL input**: a single-line `field-input` with placeholder "Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)…" and an inline **Import** button. Enter submits. The button is disabled until a URL pattern matches.
- **Subtitle line under the input**: "Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported."
- **Queue panel**: identical structure to Upload's queue — one row per submitted URL, showing:
- Source icon (use `link` glyph) and the URL (truncated middle, full URL in `title` tooltip).
- Title once known (filled in by a poll on the asset row).
- Progress bar tied to job `progress` (0100). The worker drives this between 5 and 60 % for download and 60 to 100 % for upload + DB writes.
- Status pill: queued → downloading → processing → done / failed.
- Error text if the job fails (red, one line).
- A "Clear done" button at the top of the queue.
The queue persists for the session in component state only — no separate UI table. Jobs screen remains the canonical history.
### URL validation (client-side, before POST)
Accept (case-insensitive) any of these patterns:
- `https?://(www\.|m\.)?youtube\.com/watch\?[^ ]*v=[A-Za-z0-9_-]{11}`
- `https?://youtu\.be/[A-Za-z0-9_-]{11}`
- `https?://(www\.)?youtube\.com/shorts/[A-Za-z0-9_-]{11}`
Anything else is rejected inline ("That doesn't look like a YouTube URL") without an API call. The server re-validates as a defense-in-depth check.
### Out-of-scope v1 (called out, not built)
- Pasting a playlist URL. Server returns 400 "Playlists aren't supported yet."
- Multi-line paste. Single URL only.
- Quality picker. yt-dlp format string is hard-coded.
- Cookies upload. Private videos fail with a clear message.
## 2. API
### Route
New file `services/mam-api/src/routes/imports.js`, mounted at `/api/v1/imports` in `services/mam-api/src/index.js`.
**`POST /api/v1/imports/youtube`**
Request body:
```json
{ "url": "https://youtu.be/dQw4w9WgXcQ", "projectId": "uuid", "binId": "uuid?" }
```
Behavior:
1. Validate `url` against the same three regexes as the client. 400 on miss.
2. Reject playlist URLs (URL contains `list=`) with 400 "Playlists aren't supported yet."
3. Generate `assetId = uuidv4()`.
4. Insert into `assets` with:
- `status='ingesting'`
- `media_type='video'`
- `filename = url` (placeholder; worker overwrites with the sanitized title once yt-dlp prints metadata — keeps the row queryable in the meantime)
- `display_name = url` (same; worker overwrites)
- `original_s3_key = NULL` (worker fills in)
- `source_url = url` (new column — see Schema)
- `project_id`, `bin_id`, timestamps.
5. Insert into `jobs` with `type='youtube_import'`, `asset_id`, `payload={ url }`, `status='queued'`, `progress=0`.
6. Enqueue BullMQ job on the `import` queue:
```js
await importQueue.add('youtube', { assetId, url });
```
7. Respond `200 { assetId, jobId }`.
Errors:
- Missing fields → 400.
- Bad URL → 400 with `error: 'Invalid YouTube URL'`.
- Playlist URL → 400 with `error: 'Playlists aren't supported yet'`.
- Project not found → 404.
- DB / queue failure → 500 (next(err)).
### Jobs screen integration
`services/web-ui/public/screens-jobs.jsx` already normalizes job types via a `kindMap`. Add one entry:
```js
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', youtube_import: 'YouTube' };
```
Retry, delete, and the SSE event stream all work for the new type with no further changes because they key off `job.id`, not `job.type`.
## 3. Worker
### Container changes
`services/worker/Dockerfile` gains two packages:
```dockerfile
RUN apk add --no-cache ffmpeg yt-dlp python3
```
`yt-dlp` is in the Alpine `community` repo and pulls `python3` as a runtime dep — we list it explicitly for clarity. Image grows by ~25 MB.
### New worker
`services/worker/src/workers/youtube-import.js`, registered in `services/worker/src/index.js`:
```js
const workers = [
createWorker('proxy', proxyWorker),
createWorker('thumbnail', thumbnailWorker),
createWorker('conform', conformWorker),
createWorker('import', youtubeImportWorker),
];
```
### Job handler
For a job with `{ assetId, url }`:
1. `job.updateProgress(2)` — accepted.
2. Build a temp directory `tmpdir()/yt-${jobId}`.
3. Run yt-dlp:
```sh
yt-dlp \
--no-playlist \
--no-warnings \
--restrict-filenames \
-f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b" \
--merge-output-format mp4 \
--print-json \
--newline \
-o "<tmpdir>/<assetId>.%(ext)s" \
"<url>"
```
- `--print-json` writes one JSON line at the end with title, duration, width, height, uploader, etc.
- `--newline` makes progress lines newline-terminated so we can parse them.
- `--restrict-filenames` prevents shell-special characters in temp paths.
4. Stream stdout line-by-line. Lines matching `r'\[download\]\s+(\d+(\.\d+)?)%'` map to `job.updateProgress(5 + Math.floor(pct * 0.55))` so download takes us from 5 to 60 %.
5. On yt-dlp non-zero exit: parse stderr for the first line containing `ERROR:` and use it as the job's error message. Mark the asset `status='error'`, mark the job failed, throw so BullMQ records it. Surface a friendly substitution for the common cases:
- "Private video" → "Private video — not supported."
- "Sign in to confirm your age" → "Age-restricted video — not supported."
- "Video unavailable" → "Video unavailable or removed."
- "This video is not available in your country" → "Video is geo-blocked from this region."
- HTTP 429 → "YouTube rate-limited the importer — try again later."
- Anything else → use yt-dlp's stderr line verbatim, truncated to 300 chars.
6. Parse the last stdout line as JSON to read metadata. The resulting file is `<tmpdir>/<assetId>.mp4`.
7. `getMediaInfo` (existing helper in `services/worker/src/ffmpeg/executor.js`) on that path. Use ffprobe's values for codec/fps/duration when yt-dlp's are missing or wrong.
8. Sanitize the title for the S3 filename: keep `[A-Za-z0-9 ._-]`, collapse runs of whitespace, trim, cap at 120 chars, append `.mp4`. If the sanitized title is empty, fall back to `youtube-<videoId>.mp4`.
9. Upload to `originals/{assetId}/{sanitized-title}.mp4` via the existing `uploadToS3` helper. Progress 60 → 90 %.
10. UPDATE the assets row with:
- `filename = <sanitized title>.mp4`
- `display_name = <yt-dlp title untouched>`
- `original_s3_key = originals/<assetId>/<sanitized-title>.mp4`
- `codec`, `resolution`, `fps`, `duration_ms`, `file_size` from ffprobe.
- `status = 'processing'`
- `updated_at = NOW()`
11. Enqueue a `proxy` job on the existing `proxy` queue with the same payload shape `upload.js` uses:
```js
await proxyQueue.add('generate', {
assetId,
inputKey: asset.original_s3_key,
outputKey: `proxies/${assetId}.mp4`,
});
```
12. `job.updateProgress(100)`. Return — BullMQ marks the import job done. The proxy job picks up the rest exactly like a regular upload.
13. Always `rm -rf` the temp directory in a `finally`.
### Concurrency & retries
- Default BullMQ concurrency for this queue: **1** per worker process. Two simultaneous yt-dlp invocations risk YouTube rate-limiting more than they help throughput. Configurable later via env if needed.
- No automatic BullMQ retry — yt-dlp failures are almost always permanent (private, geo, removed) and a silent retry storm would chew through quota. The Jobs screen's manual Retry button is the right knob for "this should be transient" cases.
## 4. Schema migration
New file `services/mam-api/src/db/migrations/011-youtube-import.sql`:
```sql
-- 1. Add the new job type to the enum.
-- Postgres requires ALTER TYPE ... ADD VALUE for enum changes.
ALTER TYPE job_type ADD VALUE IF NOT EXISTS 'youtube_import';
-- 2. Remember where an asset came from. NULL for everything that
-- pre-dates the importer; populated for any imported asset.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT;
```
`source_url` is exposed on the asset drawer as a "Source" line ("imported from youtu.be/…") in a follow-up PR — out of scope for this spec, but worth noting that the column exists for it.
## 5. Files touched
**New**
- `services/mam-api/src/routes/imports.js`
- `services/mam-api/src/db/migrations/011-youtube-import.sql`
- `services/worker/src/workers/youtube-import.js`
**Edited**
- `services/mam-api/src/index.js` — mount the new route.
- `services/web-ui/public/screens-ingest.jsx` — add `YouTubeImport`, export on `window`.
- `services/web-ui/public/shell.jsx` — add the nav child, extend `ingestChildren`.
- `services/web-ui/public/app.jsx` — register the route and the crumb.
- `services/web-ui/public/screens-jobs.jsx` — extend `kindMap` with `youtube_import: 'YouTube'`.
- `services/worker/src/index.js` — register the `import` queue worker.
- `services/worker/Dockerfile` — add `yt-dlp` and `python3` to the apk install line.
## 6. Risks & trade-offs
- **Worker egress**. The worker container needs outbound HTTPS to YouTube. Fine in the current homelab; will fail in a locked-down cluster. Documented in the implementation plan.
- **yt-dlp drift**. YouTube changes break old yt-dlp versions every few weeks. The Alpine package lags upstream by days. Fix is to rebuild the worker image. We do not auto-update inside the running container — too risky for an offline / locked-down deploy. If imports start failing en masse, the runbook is `docker compose build worker && docker compose up -d worker`.
- **Single-URL UX feels light**. That is deliberate for v1. Adding multi-URL paste and playlist expansion are both small follow-ups once the single-URL path is stable.
- **No copyright enforcement**. We rely on the one-line notice in the UI. If misuse becomes a real concern, the next step would be an admin allowlist of domains or a per-user import quota — not in this spec.
- **`filename = url` placeholder**. Briefly, the asset row in the Library shows the URL as the name. The worker overwrites it within seconds for a successful import. Acceptable; the Library already handles "ingesting" assets with placeholder names from the upload path.
## 7. Acceptance
The feature is done when:
- A user can navigate to **Ingest → YouTube**, paste a public YouTube URL, pick a project, click Import, and within a minute or two see the asset appear in the Library with proxy and thumbnail.
- A failed import (private video, removed video, bogus URL) shows a clear error message on both the YouTube screen's queue row and the Jobs screen.
- The Jobs screen lists "YouTube" jobs alongside Proxy / Thumbnail / Conform, with Retry working.
- `source_url` is populated on the imported asset row.
- Image rebuild + `docker compose up -d worker` is the documented recovery path if YouTube changes break yt-dlp.

View file

@ -0,0 +1,269 @@
# 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

@ -0,0 +1,84 @@
# 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

@ -0,0 +1,235 @@
# 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,8 +1,97 @@
# ── 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.
FROM debian:bookworm AS ffmpeg-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential nasm yasm pkg-config git ca-certificates python3 \
libssl-dev libx264-dev libx265-dev libvpx-dev libopus-dev \
libmp3lame-dev libsrt-openssl-dev \
libzmq3-dev zlib1g-dev libstdc++-12-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy in BMD DeckLink SDK headers and patch script
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
# Patch FFmpeg DeckLink code for SDK 16.x API changes
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 \
--enable-libx265 \
--enable-libvpx \
--enable-libopus \
--enable-libmp3lame \
--enable-libsrt \
--enable-libzmq \
--enable-decklink \
--enable-ffnvcodec \
--enable-nvenc \
--enable-cuvid \
--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
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
# Runtime deps for compiled ffmpeg libs
RUN apt-get update && apt-get install -y --no-install-recommends \
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
&& rm -rf /var/lib/apt/lists/*
# Copy compiled ffmpeg/ffprobe
COPY --from=ffmpeg-builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg-builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
COPY --from=ffmpeg-builder /usr/local/lib/ /usr/local/lib/
# DeckLink runtime .so
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
COPY . .
EXPOSE 3001
CMD ["node", "src/index.js"]

View file

@ -0,0 +1,30 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "=== Checking prerequisites ==="
if [ ! -f sdk/DeckLinkAPI.h ]; then
echo "ERROR: sdk/DeckLinkAPI.h not found."
echo ""
echo "Please download the Blackmagic DeckLink SDK 16.x from:"
echo " https://www.blackmagicdesign.com/developer/product/capture"
echo ""
echo "Then extract the Linux/include/ folder contents into:"
echo " $(pwd)/sdk/"
echo ""
echo "Required files: DeckLinkAPI.h DeckLinkAPIVersion.h DeckLinkAPIDispatch.cpp"
echo " LinuxCOM.h DeckLinkAPIModes.h DeckLinkAPITypes.h"
exit 1
fi
echo "SDK headers found:"
ls sdk/*.h sdk/*.cpp 2>/dev/null
echo ""
echo "=== Building capture container with DeckLink FFmpeg ==="
docker compose -f ../../docker-compose.yml build capture
echo ""
echo "=== Verifying DeckLink support in built image ==="
docker run --rm wild-dragon-capture ffmpeg -f decklink -list_devices true -i dummy 2>&1 | head -20

View file

@ -0,0 +1,346 @@
diff --git a/libavdevice/decklink_common.cpp b/libavdevice/decklink_common.cpp
index fe187cd..47de7ef 100644
--- a/libavdevice/decklink_common.cpp
+++ b/libavdevice/decklink_common.cpp
@@ -25,12 +25,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
-
#ifdef _WIN32
#include <DeckLinkAPI_i.c>
#else
@@ -517,8 +512,8 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
return AVERROR(EIO);
while (ret == 0 && iter->Next(&dl) == S_OK) {
- IDeckLinkOutput_v14_2_1 *output_config;
- IDeckLinkInput_v14_2_1 *input_config;
+ IDeckLinkOutput *output_config;
+ IDeckLinkInput *input_config;
const char *display_name = NULL;
const char *unique_name = NULL;
AVDeviceInfo *new_device = NULL;
@@ -532,14 +527,14 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
goto next;
if (show_outputs) {
- if (dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **)&output_config) == S_OK) {
+ if (dl->QueryInterface(IID_IDeckLinkOutput, (void **)&output_config) == S_OK) {
output_config->Release();
add = 1;
}
}
if (show_inputs) {
- if (dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **)&input_config) == S_OK) {
+ if (dl->QueryInterface(IID_IDeckLinkInput, (void **)&input_config) == S_OK) {
input_config->Release();
add = 1;
}
diff --git a/libavdevice/decklink_common.h b/libavdevice/decklink_common.h
index 095b438..6b32dc2 100644
--- a/libavdevice/decklink_common.h
+++ b/libavdevice/decklink_common.h
@@ -29,23 +29,6 @@
#define IDeckLinkProfileAttributes IDeckLinkAttributes
#endif
-#if BLACKMAGIC_DECKLINK_API_VERSION < 0x0e030000
-#define IDeckLinkInput_v14_2_1 IDeckLinkInput
-#define IDeckLinkInputCallback_v14_2_1 IDeckLinkInputCallback
-#define IDeckLinkMemoryAllocator_v14_2_1 IDeckLinkMemoryAllocator
-#define IDeckLinkOutput_v14_2_1 IDeckLinkOutput
-#define IDeckLinkVideoFrame_v14_2_1 IDeckLinkVideoFrame
-#define IDeckLinkVideoInputFrame_v14_2_1 IDeckLinkVideoInputFrame
-#define IDeckLinkVideoOutputCallback_v14_2_1 IDeckLinkVideoOutputCallback
-#define IID_IDeckLinkInput_v14_2_1 IID_IDeckLinkInput
-#define IID_IDeckLinkInputCallback_v14_2_1 IID_IDeckLinkInputCallback
-#define IID_IDeckLinkMemoryAllocator_v14_2_1 IID_IDeckLinkMemoryAllocator
-#define IID_IDeckLinkOutput_v14_2_1 IID_IDeckLinkOutput
-#define IID_IDeckLinkVideoFrame_v14_2_1 IID_IDeckLinkVideoFrame
-#define IID_IDeckLinkVideoInputFrame_v14_2_1 IID_IDeckLinkVideoInputFrame
-#define IID_IDeckLinkVideoOutputCallback_v14_2_1 IID_IDeckLinkVideoOutputCallback
-#endif
-
extern "C" {
#include "libavutil/mem.h"
#include "libavcodec/packet_internal.h"
@@ -93,16 +76,6 @@ static char *dup_cfstring_to_utf8(CFStringRef w)
#define DECKLINK_FREE(s) free((void *) s)
#endif
-#ifdef _WIN32
-#include <guiddef.h> // REFIID, IsEqualIID()
-#define DECKLINK_IsEqualIID IsEqualIID
-#else
-static inline bool DECKLINK_IsEqualIID(const REFIID& riid1, const REFIID& riid2)
-{
- return memcmp(&riid1, &riid2, sizeof(REFIID)) == 0;
-}
-#endif
-
class decklink_output_callback;
class decklink_input_callback;
@@ -120,8 +93,8 @@ typedef struct DecklinkPacketQueue {
struct decklink_ctx {
/* DeckLink SDK interfaces */
IDeckLink *dl;
- IDeckLinkOutput_v14_2_1 *dlo;
- IDeckLinkInput_v14_2_1 *dli;
+ IDeckLinkOutput *dlo;
+ IDeckLinkInput *dli;
IDeckLinkConfiguration *cfg;
IDeckLinkProfileAttributes *attr;
decklink_output_callback *output_callback;
diff --git a/libavdevice/decklink_dec.cpp b/libavdevice/decklink_dec.cpp
index 8830779..418701e 100644
--- a/libavdevice/decklink_dec.cpp
+++ b/libavdevice/decklink_dec.cpp
@@ -31,11 +31,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
extern "C" {
#include "config.h"
@@ -109,7 +105,7 @@ static VANCLineNumber vanc_line_numbers[] = {
{bmdModeUnknown, 0, -1, -1, -1}
};
-class decklink_allocator : public IDeckLinkMemoryAllocator_v14_2_1
+class decklink_allocator : public IDeckLinkMemoryAllocator
{
public:
decklink_allocator(): _refs(1) { }
@@ -133,21 +129,7 @@ public:
virtual HRESULT STDMETHODCALLTYPE Decommit() { return S_OK; }
// IUnknown methods
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkMemoryAllocator_v14_2_1)) {
- *ppv = static_cast<IDeckLinkMemoryAllocator_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
-
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
virtual ULONG STDMETHODCALLTYPE Release(void)
{
@@ -490,7 +472,7 @@ skip_packet:
}
-static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, int64_t pts)
+static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame *videoFrame, int64_t pts)
{
const uint8_t KLV_DID = 0x44;
const uint8_t KLV_IN_VANC_SDID = 0x04;
@@ -592,30 +574,17 @@ static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideo
}
}
-class decklink_input_callback : public IDeckLinkInputCallback_v14_2_1
+class decklink_input_callback : public IDeckLinkInputCallback
{
public:
explicit decklink_input_callback(AVFormatContext *_avctx);
~decklink_input_callback();
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkInputCallback_v14_2_1)) {
- *ppv = static_cast<IDeckLinkInputCallback_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void);
virtual ULONG STDMETHODCALLTYPE Release(void);
virtual HRESULT STDMETHODCALLTYPE VideoInputFormatChanged(BMDVideoInputFormatChangedEvents, IDeckLinkDisplayMode*, BMDDetectedVideoInputFormatFlags);
- virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame_v14_2_1*, IDeckLinkAudioInputPacket*);
+ virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame*, IDeckLinkAudioInputPacket*);
private:
std::atomic<int> _refs;
@@ -624,7 +593,7 @@ private:
int no_video;
int64_t initial_video_pts;
int64_t initial_audio_pts;
- IDeckLinkVideoInputFrame_v14_2_1* last_video_frame;
+ IDeckLinkVideoInputFrame* last_video_frame;
};
decklink_input_callback::decklink_input_callback(AVFormatContext *_avctx) : _refs(1)
@@ -656,7 +625,7 @@ ULONG decklink_input_callback::Release(void)
return ret;
}
-static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
+static int64_t get_pkt_pts(IDeckLinkVideoInputFrame *videoFrame,
IDeckLinkAudioInputPacket *audioFrame,
int64_t wallclock,
int64_t abs_wallclock,
@@ -710,7 +679,7 @@ static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
return pts;
}
-static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
+static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame *videoFrame)
{
IDeckLinkTimecode *timecode;
int ret = AVERROR(ENOENT);
@@ -732,7 +701,7 @@ static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational f
return ret;
}
-static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
+static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame *videoFrame)
{
AVRational frame_rate = ctx->video_st->r_frame_rate;
int ret;
@@ -757,7 +726,7 @@ static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimec
}
HRESULT decklink_input_callback::VideoInputFrameArrived(
- IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
+ IDeckLinkVideoInputFrame *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
{
void *frameBytes;
void *audioFrameBytes;
@@ -1172,7 +1141,7 @@ av_cold int ff_decklink_read_header(AVFormatContext *avctx)
goto error;
/* Get input device. */
- if (ctx->dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **) &ctx->dli) != S_OK) {
+ if (ctx->dl->QueryInterface(IID_IDeckLinkInput, (void **) &ctx->dli) != S_OK) {
av_log(avctx, AV_LOG_ERROR, "Could not open input device from '%s'\n",
avctx->url);
ret = AVERROR(EIO);
diff --git a/libavdevice/decklink_enc.cpp b/libavdevice/decklink_enc.cpp
index d2e246c..cb8f917 100644
--- a/libavdevice/decklink_enc.cpp
+++ b/libavdevice/decklink_enc.cpp
@@ -28,11 +28,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
extern "C" {
#include "libavformat/avformat.h"
@@ -52,7 +48,7 @@ extern "C" {
#endif
/* DeckLink callback class declaration */
-class decklink_frame : public IDeckLinkVideoFrame_v14_2_1
+class decklink_frame : public IDeckLinkVideoFrame
{
public:
decklink_frame(struct decklink_ctx *ctx, AVFrame *avframe, AVCodecID codec_id, int height, int width) :
@@ -115,20 +111,7 @@ public:
_ancillary->AddRef();
return S_OK;
}
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoFrame_v14_2_1)) {
- *ppv = static_cast<IDeckLinkVideoFrame_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
virtual ULONG STDMETHODCALLTYPE Release(void)
{
@@ -155,10 +138,10 @@ private:
std::atomic<int> _refs;
};
-class decklink_output_callback : public IDeckLinkVideoOutputCallback_v14_2_1
+class decklink_output_callback : public IDeckLinkVideoOutputCallback
{
public:
- virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame_v14_2_1 *_frame, BMDOutputFrameCompletionResult result)
+ virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame *_frame, BMDOutputFrameCompletionResult result)
{
decklink_frame *frame = static_cast<decklink_frame *>(_frame);
struct decklink_ctx *ctx = frame->_ctx;
@@ -176,20 +159,7 @@ public:
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE ScheduledPlaybackHasStopped(void) { return S_OK; }
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoOutputCallback_v14_2_1)) {
- *ppv = static_cast<IDeckLinkVideoOutputCallback_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return 1; }
virtual ULONG STDMETHODCALLTYPE Release(void) { return 1; }
};
@@ -769,7 +739,7 @@ static int decklink_write_video_packet(AVFormatContext *avctx, AVPacket *pkt)
ctx->first_pts = pkt->pts;
/* Schedule frame for playback. */
- hr = ctx->dlo->ScheduleVideoFrame(frame,
+ hr = ctx->dlo->ScheduleVideoFrame((class IDeckLinkVideoFrame *) frame,
pkt->pts * ctx->bmd_tb_num,
ctx->bmd_tb_num, ctx->bmd_tb_den);
/* Pass ownership to DeckLink, or release on failure */
@@ -904,7 +874,7 @@ av_cold int ff_decklink_write_header(AVFormatContext *avctx)
return ret;
/* Get output device. */
- if (ctx->dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **) &ctx->dlo) != S_OK) {
+ if (ctx->dl->QueryInterface(IID_IDeckLinkOutput, (void **) &ctx->dlo) != S_OK) {
av_log(avctx, AV_LOG_ERROR, "Could not open output device from '%s'\n",
avctx->url);
ret = AVERROR(EIO);

View file

@ -0,0 +1,21 @@
#!/usr/bin/env python3
# Apply the upstream FFmpeg master decklink SDK-16 compatibility patch on top
# of the release/7.1 source. The patch renames every IDeckLink* interface and
# helper to its _v14_2_1 versioned form so the call sites keep working against
# SDK 16's headers (which only retain the versioned aliases). Cherry-picking
# individual replacements like the previous regex patch produced inconsistent
# code that compiled but silently dropped every video frame.
import subprocess, sys, pathlib
patch = pathlib.Path('/decklink-sdk16.patch')
if not patch.exists():
print('FATAL: /decklink-sdk16.patch not found in build context', file=sys.stderr)
sys.exit(1)
# Patch was produced as `git diff HEAD FETCH_HEAD` where HEAD=release/7.1 and
# FETCH_HEAD=master, so we apply it in REVERSE to move 7.1 → master.
result = subprocess.run(
['git', 'apply', '-R', '--verbose', str(patch)],
cwd='/ffmpeg', capture_output=True, text=True,
)
print(result.stdout)
print(result.stderr, file=sys.stderr)
sys.exit(result.returncode)

View file

@ -1,9 +1,112 @@
import { spawn } from 'child_process';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration.
const VIDEO_CODECS = {
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' },
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
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',
},
};
const AUDIO_CODECS = {
pcm_s16le: { args: ['-c:a', 'pcm_s16le'], bitrateControl: false },
pcm_s24le: { args: ['-c:a', 'pcm_s24le'], bitrateControl: false },
pcm_s32le: { args: ['-c:a', 'pcm_s32le'], bitrateControl: false },
aac: { args: ['-c:a', 'aac'], bitrateControl: true },
ac3: { args: ['-c:a', 'ac3'], bitrateControl: true },
opus: { args: ['-c:a', 'libopus'], bitrateControl: true },
flac: { args: ['-c:a', 'flac'], bitrateControl: false },
};
const CONTAINER_FMT = {
mov: 'mov',
mp4: 'mp4',
mkv: 'matroska',
mxf: 'mxf',
ts: 'mpegts',
};
const CONTAINER_EXT = {
mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
};
function buildEncodeArgs({
codec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container, isNetwork, isProxy = false,
}) {
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
const args = [];
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
args.push(...v.args);
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
if (framerate && framerate !== 'native') args.push('-r', framerate);
args.push(...a.args);
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
if (audioChannels) args.push('-ac', String(audioChannels));
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+frag_keyframe+empty_moov');
}
args.push('-f', fmt);
return args;
}
class CaptureManager {
constructor() {
this.state = {
@ -11,7 +114,6 @@ class CaptureManager {
sessionId: null,
processes: {},
currentSession: {},
// Live signal metrics derived from ffmpeg stderr
framesReceived: 0,
currentFps: 0,
lastFrameAt: null,
@ -24,14 +126,13 @@ class CaptureManager {
* Returns { inputArgs, isNetwork }
* @private
*/
_buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
if (sourceType === 'srt') {
let url;
if (listen) {
const port = listenPort || 9000;
url = `srt://0.0.0.0:${port}?mode=listener`;
} else {
// Caller mode — ensure mode=caller is appended if not already present
url = sourceUrl;
if (!url.includes('mode=')) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
@ -52,24 +153,94 @@ 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)').
// 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 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);
}
}
return {
inputArgs: ['-f', 'decklink', '-i', String(device)],
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
isNetwork: false,
};
}
/**
* Start a new capture session
* @param {Object} params
* - projectId, binId, clipName always required
* - device DeckLink device index (SDI only)
* - sourceType 'sdi' | 'srt' | 'rtmp' (default: 'sdi')
* - sourceUrl URL for caller mode (SRT/RTMP caller)
* - listen true for listener/server mode
* - listenPort port to bind in listener mode
* - streamKey RTMP stream key for listener mode
* @returns {Object} Session info
* Start a new capture session.
*
* Codec parameters all have sensible defaults so legacy callers (no codec
* args) still produce ProRes HQ master + H.264 proxy.
*/
async start({
assetId,
@ -82,6 +253,23 @@ class CaptureManager {
listen = false,
listenPort,
streamKey,
// ── Recording codec ─────────────────────────────────────────────
videoCodec = 'prores_hq',
videoBitrate = null,
framerate = null,
audioCodec = 'pcm_s24le',
audioBitrate = null,
audioChannels = 2,
container = 'mov',
// ── Proxy codec ─────────────────────────────────────────────────
proxyEnabled = true,
proxyVideoCodec = 'h264',
proxyVideoBitrate = '8M',
proxyFramerate = null,
proxyAudioCodec = 'aac',
proxyAudioBitrate = '192k',
proxyAudioChannels = 2,
proxyContainer = 'mp4',
}) {
this._assetIdForHls = assetId || null;
if (this.state.recording) {
@ -89,57 +277,96 @@ class CaptureManager {
}
const sessionId = uuidv4();
const hiresKey = `projects/${projectId}/masters/${clipName}.mov`;
const hiresExt = CONTAINER_EXT[container] || 'mov';
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
// Network sources cannot be opened by two FFmpeg processes simultaneously.
// proxyKey is null for SRT/RTMP — the BullMQ worker generates the proxy
// after the recording stops (same pipeline used for uploaded files).
const proxyKey = sourceType === 'sdi'
? `projects/${projectId}/proxies/${clipName}.mp4`
// Growing-files: write master to the local SMB share instead of streaming
// to S3. Path is relative to the container's GROWING_PATH mount.
const growingPath = GROWING_ENABLED
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
: null;
if (growingPath) {
try { mkdirSync(dirname(growingPath), { recursive: true }); }
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;
const startedAt = new Date().toISOString();
const { inputArgs, isNetwork } = this._buildInputArgs({
sourceType,
device,
sourceUrl,
listen,
listenPort,
streamKey,
const { inputArgs, isNetwork } = await this._buildInputArgs({
sourceType, device, sourceUrl, listen, listenPort, streamKey,
});
// ProRes hires — fragmented moov for pipe-safe output on network sources
const hiresCodecArgs = isNetwork
? [
'-map', '0:v:0?',
'-map', '0:a:0?',
'-c:v', 'prores_ks',
'-profile:v', '3',
'-c:a', 'pcm_s24le',
'-movflags', '+frag_keyframe+empty_moov',
'-f', 'mov',
]
: [
'-c:v', 'prores_ks',
'-profile:v', '3',
'-c:a', 'pcm_s24le',
'-f', 'mov',
];
// Spawn hires FFmpeg process
const hiresProcess = spawn('ffmpeg', [
...inputArgs,
...hiresCodecArgs,
'pipe:1',
], {
stdio: ['ignore', 'pipe', 'pipe'],
const hiresCodecArgs = buildEncodeArgs({
codec: videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container,
isNetwork,
isProxy: false,
});
const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// When growing-files is on, write directly to the SMB share so Premier
// can mount and edit the live file. Promotion worker uploads to S3 on EOF.
// Otherwise, stream the master to S3 via stdout pipe (legacy behavior).
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 hiresUpload = growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
const processes = { hires: hiresProcess };
const uploads = { hires: hiresUpload };
// ── HLS tee for network sources (live preview in the UI) ──────────
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && this._assetIdForHls) {
@ -168,49 +395,22 @@ class CaptureManager {
}
}
hiresProcess.stderr.on('data', (data) => {
const text = data.toString();
console.error(`[HIRES] ${text}`);
// Track stream signal: ffmpeg prints "frame= 123 fps= 30 ..." every ~1s
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.currentFps = parseFloat(m[2]);
this.state.lastFrameAt = new Date().toISOString();
}
// Surface fatal-looking lines for the status endpoint
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
}
});
// SDI only: spawn a second FFmpeg process for the proxy.
// DeckLink cards can be opened simultaneously by multiple processes;
// network streams cannot.
if (!isNetwork) {
const proxyProcess = spawn('ffmpeg', [
...inputArgs,
'-c:v', 'libx264',
'-preset', 'fast',
'-b:v', '10M',
'-c:a', 'aac',
'-b:a', '192k',
'-movflags', '+frag_keyframe+empty_moov',
'-f', 'mp4',
'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}`);
});
}
// 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.
this.state.recording = true;
this.state.sessionId = sessionId;
@ -229,19 +429,21 @@ class CaptureManager {
sourceUrl,
hiresKey,
proxyKey,
growingPath,
startedAt,
duration: 0,
uploads,
codecs: {
videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels, container,
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
},
};
return this._formatSessionResponse();
}
/**
* Stop the current capture session
* @param {string} sessionId - Session ID to stop
* @returns {Object} Completed session info
*/
async stop(sessionId) {
if (!this.state.recording || this.state.sessionId !== sessionId) {
throw new Error('No active capture session or session ID mismatch');
@ -249,19 +451,13 @@ class CaptureManager {
const { processes, currentSession } = this.state;
// Gracefully terminate all FFmpeg processes
if (processes.hires) {
processes.hires.kill('SIGINT');
}
if (processes.hires) processes.hires.kill('SIGINT');
if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
try {
// Wait for all in-flight S3 uploads to complete
const uploadPromises = [currentSession.uploads.hires];
if (currentSession.uploads.proxy) {
uploadPromises.push(currentSession.uploads.proxy);
}
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
await Promise.all(uploadPromises);
} catch (error) {
console.error('Error during upload completion:', error);
@ -272,11 +468,15 @@ class CaptureManager {
const stopTime = new Date(stoppedAt);
const duration = Math.round((stopTime - startTime) / 1000);
// Reset state
this.state.recording = false;
this.state.sessionId = null;
this.state.processes = {};
// No frames received → the upload (if any) produced a 0-byte object.
// Surface that so the shutdown handler can mark the asset as 'error'
// instead of posting a broken hi-res key downstream.
const framesReceived = this.state.framesReceived;
return {
sessionId,
projectId: currentSession.projectId,
@ -284,23 +484,18 @@ class CaptureManager {
clipName: currentSession.clipName,
sourceType: currentSession.sourceType,
hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey, // null for SRT/RTMP
proxyKey: currentSession.proxyKey,
growingPath: currentSession.growingPath || null,
startedAt: currentSession.startedAt,
stoppedAt,
duration,
framesReceived,
empty: framesReceived === 0,
};
}
/**
* Get current capture status
* @returns {Object} Current state
*/
getStatus() {
if (!this.state.recording) {
return {
recording: false,
};
}
if (!this.state.recording) return { recording: false };
const startTime = new Date(this.state.currentSession.startedAt);
const now = new Date();
@ -330,13 +525,10 @@ class CaptureManager {
lastFrameAt,
msSinceFrame,
lastError: this.state.lastError,
codecs: this.state.currentSession.codecs,
};
}
/**
* Format session response
* @private
*/
_formatSessionResponse() {
const { currentSession, sessionId } = this.state;
return {
@ -349,8 +541,10 @@ class CaptureManager {
hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey,
startedAt: currentSession.startedAt,
codecs: currentSession.codecs,
};
}
}
export default new CaptureManager();
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };

View file

@ -9,25 +9,40 @@ 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 || '';
// Middleware
app.use(cors());
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// Routes
app.use('/capture', captureRoutes);
// Start server
const server = app.listen(PORT, () => {
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
bootstrapAutoStart();
});
// Mapped from the env vars routes/recorders.js writes into the container.
// Empty strings collapse to undefined so capture-manager's defaults win.
function envOpt(name) {
const v = process.env[name];
return v === undefined || v === '' ? undefined : v;
}
function envInt(name) {
const v = envOpt(name);
if (v === undefined) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
function envBool(name) {
const v = envOpt(name);
if (v === undefined) return undefined;
return v === 'true' || v === '1' || v === 'yes';
}
async function bootstrapAutoStart() {
const recorderId = process.env.RECORDER_ID;
const sourceType = process.env.SOURCE_TYPE;
@ -44,29 +59,42 @@ async function bootstrapAutoStart() {
}
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
const listenPort = process.env.LISTEN_PORT
? parseInt(process.env.LISTEN_PORT, 10)
: undefined;
const streamKey = process.env.STREAM_KEY || undefined;
const sourceUrl = process.env.SOURCE_URL || undefined;
if (sourceType === 'sdi') {
console.warn('[bootstrap] SDI auto-start not supported');
return;
}
const listenPort = envInt('LISTEN_PORT');
const streamKey = envOpt('STREAM_KEY');
const sourceUrl = envOpt('SOURCE_URL');
const device = envInt('DEVICE_INDEX');
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
try {
const session = await captureManager.start({
assetId: process.env.ASSET_ID || null,
assetId: envOpt('ASSET_ID') || null,
projectId,
binId: process.env.BIN_ID || null,
binId: envOpt('BIN_ID') || null,
clipName,
device,
sourceType,
sourceUrl,
listen,
listenPort,
streamKey,
// Recording codec — recorders.js passes these straight through
videoCodec: envOpt('RECORDING_CODEC') || 'prores_hq',
videoBitrate: envOpt('RECORDING_VIDEO_BITRATE'),
framerate: envOpt('RECORDING_FRAMERATE'),
audioCodec: envOpt('RECORDING_AUDIO_CODEC') || 'pcm_s24le',
audioBitrate: envOpt('RECORDING_AUDIO_BITRATE'),
audioChannels: envInt('RECORDING_AUDIO_CHANNELS') ?? 2,
container: envOpt('RECORDING_CONTAINER') || 'mov',
proxyEnabled: envBool('PROXY_ENABLED') ?? true,
proxyVideoCodec: envOpt('PROXY_CODEC') || 'h264',
proxyVideoBitrate: envOpt('PROXY_VIDEO_BITRATE') || '8M',
proxyFramerate: envOpt('PROXY_FRAMERATE'),
proxyAudioCodec: envOpt('PROXY_AUDIO_CODEC') || 'aac',
proxyAudioBitrate: envOpt('PROXY_AUDIO_BITRATE') || '192k',
proxyAudioChannels: envInt('PROXY_AUDIO_CHANNELS') ?? 2,
proxyContainer: envOpt('PROXY_CONTAINER') || 'mp4',
});
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
} catch (err) {
@ -86,31 +114,68 @@ async function gracefulShutdown(signal) {
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
try {
const completed = await captureManager.stop(status.sessionId);
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,
clipName: completed.clipName,
sourceType: completed.sourceType,
hiresKey: completed.hiresKey,
proxyKey: completed.proxyKey,
needsProxy: completed.proxyKey === null,
duration: completed.duration,
capturedAt: completed.startedAt,
}),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] asset registered with mam-api');
const liveAssetId = process.env.ASSET_ID || null;
// No frames received → the source never connected (bad SRT URL, dead
// SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
// point is 0 bytes and would just clog the proxy queue with "moov
// atom not found" failures. Mark the pre-created live asset as
// 'error' and skip the POST /assets registration entirely.
if (completed.empty) {
console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
if (liveAssetId) {
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}` } : {}) },
});
} 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}` } : {}) },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,
clipName: completed.clipName,
sourceType: completed.sourceType,
hiresKey: completed.hiresKey,
proxyKey: completed.proxyKey,
needsProxy: completed.proxyKey === null,
duration: completed.duration,
capturedAt: completed.startedAt,
}),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] asset registered with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to register asset:', mamErr.message);
}
} catch (mamErr) {
console.error('[shutdown] failed to register asset:', mamErr.message);
}
} catch (err) {
console.error('[shutdown] error during stop:', err);

View file

@ -1,5 +1,6 @@
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';
@ -87,7 +88,7 @@ router.get('/devices', (req, res) => {
let output = '';
try {
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
output = execSync('ffmpeg -sources decklink 2>&1', {
encoding: 'utf-8',
});
} catch (error) {
@ -95,13 +96,13 @@ router.get('/devices', (req, res) => {
output = error.stderr ? error.stderr.toString() : error.toString();
}
// Parse ffmpeg output for DeckLink device names
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
// Parse ffmpeg output for DeckLink device names.
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
const lines = output.split('\n');
let deviceIndex = 0;
for (const line of lines) {
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (match) {
devices.push({
index: deviceIndex,
@ -118,6 +119,57 @@ 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
@ -137,10 +189,10 @@ router.post('/probe', async (req, res) => {
if (source_type === 'sdi') {
try {
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
const raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const devices = [];
for (const line of raw.split('\n')) {
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (m) devices.push(m[1]);
}
return res.json({ ok: true, source_type, devices });
@ -150,6 +202,28 @@ 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.' });
}

View file

@ -0,0 +1,330 @@
import express from 'express';
import { execSync, spawn } from 'child_process';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
import net from 'net';
function parseUrl(u) {
try {
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
if (!m) return null;
return { host: m[1], port: parseInt(m[2] || '0', 10) };
} catch (_) { return null; }
}
async function checkReachable(host, port, sourceType) {
if (!port) return { ok: true };
if (sourceType === 'srt') return await udpSendProbe(host, port);
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
return { ok: true };
}
function udpSendProbe(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
sock.on('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
} else {
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
}
});
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
setTimeout(() => finish({ ok: true }), 1500);
});
}
function tcpConnectProbe(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
sock.setTimeout(2500);
sock.once('connect', () => finish({ ok: true }));
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
sock.once('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
});
sock.connect(port, host);
});
}
function classifyProbeError(raw, sourceType) {
const r = (raw || '').toLowerCase();
if (sourceType === 'srt') {
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
}
}
if (sourceType === 'rtmp') {
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
}
return raw;
}
const router = express.Router();
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
/**
* GET /devices
* List available DeckLink devices
*/
router.get('/devices', (req, res) => {
try {
const devices = [];
let output = '';
try {
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
encoding: 'utf-8',
});
} catch (error) {
// ffmpeg returns non-zero, but stderr is still captured
output = error.stderr ? error.stderr.toString() : error.toString();
}
// Parse ffmpeg output for DeckLink device names
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
const lines = output.split('\n');
let deviceIndex = 0;
for (const line of lines) {
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
if (match) {
devices.push({
index: deviceIndex,
name: match[1],
});
deviceIndex++;
}
}
res.json({ devices });
} catch (error) {
console.error('Error listing devices:', error);
res.status(500).json({ error: 'Failed to list devices' });
}
});
/**
* GET /status
* Get current capture status
*/
router.get('/status', (req, res) => {
try {
const status = captureManager.getStatus();
res.json(status);
} catch (error) {
console.error('Error getting status:', error);
res.status(500).json({ error: 'Failed to get status' });
}
});
router.post('/probe', async (req, res) => {
try {
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
if (source_type === 'sdi') {
try {
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
const devices = [];
for (const line of raw.split('\n')) {
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
if (m) devices.push(m[1]);
}
return res.json({ ok: true, source_type, devices });
} catch (err) {
const out = (err.stderr || err.stdout || err.toString()).toString();
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
}
}
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.' });
}
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
// an actionable error instead of the opaque libsrt "Input/output error".
const parsed = parseUrl(source_url);
if (!parsed) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
}
const reach = await checkReachable(parsed.host, parsed.port, source_type);
if (!reach.ok) {
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
}
let url = source_url;
if (source_type === 'srt' && !/mode=/.test(url)) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
const ff = spawn('ffprobe', args);
let stdout = '', stderr = '';
ff.stdout.on('data', (c) => { stdout += c; });
ff.stderr.on('data', (c) => { stderr += c; });
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
ff.on('close', (code) => {
clearTimeout(killer);
if (code !== 0) {
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
const friendly = classifyProbeError(rawErr, source_type);
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
}
try {
const parsed = JSON.parse(stdout);
const streams = (parsed.streams || []).map(s => ({
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
sample_rate: s.sample_rate, channels: s.channels,
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
}));
return res.json({ ok: true, source_type, source_url,
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
streams });
} catch (err) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
}
});
} catch (error) {
console.error('Probe error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /start
* Start a new capture session
*
* Body (SDI):
* { project_id, clip_name, device, bin_id?, source_type? }
*
* Body (SRT/RTMP caller):
* { project_id, clip_name, source_type, source_url, bin_id? }
*
* Body (SRT/RTMP listener):
* { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? }
*/
router.post('/start', async (req, res) => {
try {
const {
project_id,
bin_id,
clip_name,
device,
source_type = 'sdi',
source_url,
listen = false,
listen_port,
stream_key,
} = req.body;
if (!project_id || !clip_name) {
return res.status(400).json({
error: 'Missing required fields: project_id, clip_name',
});
}
// Source-specific validation
if (source_type === 'sdi') {
if (device === undefined || device === null) {
return res.status(400).json({ error: 'SDI source requires: device' });
}
} else if (source_type === 'srt' || source_type === 'rtmp') {
if (!listen && !source_url) {
return res.status(400).json({
error: `${source_type.toUpperCase()} caller mode requires: source_url`,
});
}
} else {
return res.status(400).json({
error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`,
});
}
const session = await captureManager.start({
projectId: project_id,
binId: bin_id || null,
clipName: clip_name,
device,
sourceType: source_type,
sourceUrl: source_url,
listen,
listenPort: listen_port,
streamKey: stream_key,
});
res.json(session);
} catch (error) {
console.error('Error starting capture:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /stop
* Stop the current capture session
* Body: { session_id }
*/
router.post('/stop', async (req, res) => {
try {
const { session_id } = req.body;
if (!session_id) {
return res.status(400).json({ error: 'Missing required field: session_id' });
}
const completedSession = await captureManager.stop(session_id);
// Register asset with mam-api.
// If proxyKey is null (SRT/RTMP source), set needsProxy=true so the
// worker generates a proxy from the hires file asynchronously.
try {
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completedSession.projectId,
binId: completedSession.binId,
clipName: completedSession.clipName,
sourceType: completedSession.sourceType,
hiresKey: completedSession.hiresKey,
proxyKey: completedSession.proxyKey,
needsProxy: completedSession.proxyKey === null,
duration: completedSession.duration,
capturedAt: completedSession.startedAt,
}),
});
if (!mamResponse.ok) {
console.warn(
`MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`,
);
}
} catch (mamError) {
console.warn('Failed to register asset with MAM API:', mamError.message);
}
res.json(completedSession);
} catch (error) {
console.error('Error stopping capture:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View file

@ -1,65 +0,0 @@
# 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/

View file

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

View file

@ -1,135 +0,0 @@
# 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

@ -1,387 +0,0 @@
# 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

@ -1,19 +0,0 @@
# 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

@ -1,20 +0,0 @@
# 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.

View file

@ -1,21 +0,0 @@
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.

View file

@ -1,308 +0,0 @@
# 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

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

View file

@ -1,70 +0,0 @@
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

@ -1,29 +0,0 @@
<!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

@ -1,64 +0,0 @@
{
"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

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

View file

@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 389 B

View file

@ -1,19 +0,0 @@
{
"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

@ -1,47 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,168 +0,0 @@
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

@ -1,108 +0,0 @@
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

@ -1,111 +0,0 @@
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

@ -1,176 +0,0 @@
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

@ -1,164 +0,0 @@
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

@ -1,305 +0,0 @@
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

@ -1,117 +0,0 @@
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

@ -1,108 +0,0 @@
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

@ -1,225 +0,0 @@
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

@ -1,184 +0,0 @@
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

@ -1,107 +0,0 @@
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

@ -1,626 +0,0 @@
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

@ -1,140 +0,0 @@
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

@ -1,217 +0,0 @@
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>
);
}

View file

@ -1,363 +0,0 @@
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

@ -1,206 +0,0 @@
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

@ -1,240 +0,0 @@
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

@ -1,180 +0,0 @@
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

@ -1,136 +0,0 @@
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

@ -1,169 +0,0 @@
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

@ -1,226 +0,0 @@
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

@ -1,121 +0,0 @@
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

@ -1,156 +0,0 @@
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

@ -1,170 +0,0 @@
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

@ -1,149 +0,0 @@
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

@ -1,211 +0,0 @@
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

@ -1,107 +0,0 @@
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

@ -1,308 +0,0 @@
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

@ -1,267 +0,0 @@
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

@ -1,167 +0,0 @@
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

@ -1,379 +0,0 @@
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

@ -1,153 +0,0 @@
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

@ -1,287 +0,0 @@
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

@ -1,202 +0,0 @@
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

@ -1,176 +0,0 @@
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

@ -1,117 +0,0 @@
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

@ -1,347 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,467 +0,0 @@
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

@ -1,213 +0,0 @@
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

@ -1,152 +0,0 @@
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

@ -1,293 +0,0 @@
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

@ -1,120 +0,0 @@
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

@ -1,78 +0,0 @@
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

@ -1,179 +0,0 @@
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

@ -1,104 +0,0 @@
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>
);
}

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