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
This commit is contained in:
claude 2026-05-23 04:26:03 +00:00
parent 7700548dee
commit 7170a9945c
2 changed files with 170 additions and 32 deletions

108
README.md
View file

@ -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/<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
## 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.

View file

@ -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 }) {
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
<div><span className={`badge ${badge.cls}`}>{badge.label}</span></div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
{s.status === 'pending' && (
<button className="btn ghost sm" onClick={() => setEditing(s)} title="Edit name / time / recurrence">Edit</button>
)}
{(s.status === 'pending' || s.status === 'running') && (
<button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
)}
@ -758,6 +762,96 @@ function Schedule({ navigate }) {
</div>
{showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
</div>
);
}
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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Edit scheduled recording</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" autoFocus value={form.name}
onChange={e => set('name', e.target.value)} />
</div>
<div className="field">
<label className="field-label">Recorder</label>
<input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
style={{ color: 'var(--text-3)' }} />
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned delete + recreate to change.</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label className="field-label">Start</label>
<input className="field-input mono" type="datetime-local"
value={form.start_at} onChange={e => set('start_at', e.target.value)} />
</div>
<div className="field">
<label className="field-label">End</label>
<input className="field-input mono" type="datetime-local"
value={form.end_at} onChange={e => set('end_at', e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Recurrence</label>
<select className="field-input" value={form.recurrence}
onChange={e => set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
<option value="none">One-shot (no repeat)</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</button>
</div>
</div>
</div>
);
}