diff --git a/README.md b/README.md index d0951e8..10220ab 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,104 @@ -# Wild Dragon +# Dragonflight -Self-hosted Media Asset Management platform built to replace Grass Valley AMPP FramelightX. +Self-hosted broadcast media-asset management. Replaces Grass Valley AMPP +FramelightX. SDI / SRT / RTMP ingest, growing-file editing via Premiere +Pro, S3-compatible storage, scheduling, and a queue-driven proxy pipeline. + +> Repo renamed from `wild-dragon` → `dragonflight` (2026-05-23). The old +> URL still redirects. + +## Features + +- **Ingest** — SRT (caller + listener), RTMP, and SDI capture via + Blackmagic DeckLink cards (FFmpeg patched against SDK 16.x); per-recorder + codec settings (ProRes / H.264 / DNxHR / HEVC) and audio routing +- **Growing-file editing** — capture writes the hi-res master to a local + SMB landing zone; editors can mount the share in Premiere Pro and edit + the live file via the included CEP panel, then relink to the final S3 + master after promotion +- **Recorder scheduler** — one-shot, daily, or weekly windows; a 15s tick + loop fires the existing /recorders/:id/start + /stop endpoints +- **Library** — projects, bins, asset detail with frame-anchored + persistent comments, right-click context menu (move-to-bin, rename, + delete), and a global cmd/ctrl-K search across assets / projects / + recorders / jobs / users +- **Jobs** — BullMQ-backed proxy + thumbnail queue with per-job retry, + bulk "retry all failed", and inline error messages +- **Settings** — S3 (with env-var fallback), global proxy encoder + (CPU/libx264 or GPU/NVENC/VAAPI), growing-files config, capture SDK + uploader (Blackmagic / AJA / Deltacast) +- **Cluster** — primary + worker topology with heartbeat health, remote + node-agent for off-host DeckLink capture +- **API** — `deploy/api-smoke.sh` exercises every endpoint (27 routes, + pass/fail summary) ## Services | 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) | +| **web-ui** | 47434 | Browser SPA + capture controls | +| **mam-api** | 47432 | REST API + recorder orchestration + scheduler tick | +| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP capture sidecar | +| **worker** | — | BullMQ proxy + thumbnail workers | +| **db** | 5432 | PostgreSQL 16 | +| **queue** | 6379 | Redis 7 | -## Quick Start +## 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//index.m3u8 + └─ master output + ├─ growing_enabled=true: + │ /growing//.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 +## 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; ProRes, H.264, HEVC, + DNxHR, MOV/MP4/MXF containers +- **Storage:** S3-compatible (RustFS) for masters + proxies + thumbnails + +## Operations + +- `deploy/api-smoke.sh` — verify every API endpoint after a deploy +- `deploy/onboard-node.sh` — provision a remote worker host (DeckLink + cards on a separate machine) +- `deploy/test-cluster.sh` — primary↔worker connectivity smoke +- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + + growing-file capture flow ## License -MIT +Proprietary — Wild Dragon LLC, all rights reserved. diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 4888c94..bbe0795 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -653,6 +653,7 @@ function Schedule({ navigate }) { const [schedules, setSchedules] = React.useState(null); const [recorders, setRecorders] = React.useState([]); const [showNew, setShowNew] = React.useState(false); + const [editing, setEditing] = React.useState(null); const [filter, setFilter] = React.useState('upcoming'); const load = React.useCallback(() => { @@ -743,6 +744,9 @@ function Schedule({ navigate }) {
{s.recurrence === 'none' ? 'one-shot' : s.recurrence}
{badge.label}
+ {s.status === 'pending' && ( + + )} {(s.status === 'pending' || s.status === 'running') && ( )} @@ -758,6 +762,96 @@ function Schedule({ navigate }) {
{showNew && setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />} + {editing && setEditing(null)} onSaved={() => { setEditing(null); load(); }} />} + + ); +} + +function EditScheduleModal({ schedule, onClose, onSaved }) { + const toLocalInput = (iso) => { + const d = new Date(iso); + const tz = d.getTimezoneOffset() * 60_000; + return new Date(d.getTime() - tz).toISOString().slice(0, 16); + }; + const [form, setForm] = React.useState({ + name: schedule.name, + start_at: toLocalInput(schedule.start_at), + end_at: toLocalInput(schedule.end_at), + recurrence: schedule.recurrence || 'none', + }); + const [saving, setSaving] = React.useState(false); + const [err, setErr] = React.useState(null); + + const set = (k, v) => setForm(p => ({ ...p, [k]: v })); + + const submit = () => { + setErr(null); + if (!form.name.trim()) return setErr('Name is required'); + const startD = new Date(form.start_at); + const endD = new Date(form.end_at); + if (endD <= startD) return setErr('End must be after start'); + setSaving(true); + window.ZAMPP_API.fetch('/schedules/' + schedule.id, { + method: 'PUT', + body: JSON.stringify({ + name: form.name.trim(), + start_at: startD.toISOString(), + end_at: endD.toISOString(), + recurrence: form.recurrence, + }), + }) + .then(onSaved) + .catch(e => { setSaving(false); setErr(e.message || 'Save failed'); }); + }; + + return ( +
+
e.stopPropagation()}> +
+
Edit scheduled recording
+ +
+
+
+ + set('name', e.target.value)} /> +
+
+ + +
Recorder can't be reassigned — delete + recreate to change.
+
+
+
+ + set('start_at', e.target.value)} /> +
+
+ + set('end_at', e.target.value)} /> +
+
+
+ + +
+ {err &&
{err}
} +
+
+ + +
+
); }