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 ## Services
| Service | Port | Description | | Service | Port | Description |
|---------|------|-------------| |---------|------|-------------|
| **web-ui** | 8080 | Browser-based MAM interface + capture controls | | **web-ui** | 47434 | Browser SPA + capture controls |
| **mam-api** | 3000 | REST API — assets, projects, bins, jobs | | **mam-api** | 47432 | REST API + recorder orchestration + scheduler tick |
| **capture** | 3001 | SDI capture daemon (Blackmagic DeckLink + FFmpeg) | | **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP capture sidecar |
| **worker** | — | Async job processor (proxy gen, thumbnails, conform) | | **worker** | — | BullMQ proxy + thumbnail workers |
| **db** | 5432 | PostgreSQL 16 metadata store | | **db** | 5432 | PostgreSQL 16 |
| **queue** | 6379 | Redis 7 job queue (BullMQ) | | **queue** | 6379 | Redis 7 |
## Quick Start ## Quick start
```bash ```bash
# Clone # Clone (repo renamed; old URL still redirects)
git clone https://forge.wilddragon.net/zgaetano/wild-dragon.git git clone https://forge.wilddragon.net/zgaetano/dragonflight.git
cd wild-dragon cd dragonflight
# Configure # Configure
cp .env.example .env cp .env.example .env
# Edit .env with your S3 credentials and secrets # Edit .env — S3 credentials + SESSION_SECRET at minimum
# Launch # Launch
docker compose up -d docker compose up -d
# Open # Open
open http://localhost:8080 open http://localhost:47434
``` ```
## Architecture ## Architecture
``` ```
SDI Input (DeckLink) → capture service → dual FFmpeg streams SDI / SRT / RTMP ──► capture (FFmpeg)
├─ HiRes (ProRes) → S3 ├─ HLS preview tee ──► /live/<assetId>/index.m3u8
└─ Proxy (H.264) → S3 └─ master output
├─ growing_enabled=true:
web-ui ← mam-api ← PostgreSQL ← worker (BullMQ) │ /growing/<projectId>/<clip>.mov
├─ proxy_gen │ (Premiere mounts SMB, edits live)
├─ thumbnail │ └─► promotion worker uploads to S3
└─ conform (EDL → FFmpeg → export)
└─ 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 - **Runtime:** Node.js 22, Docker Compose
- **Frontend:** Vanilla HTML/CSS/JS - **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
- **Database:** PostgreSQL 16 - **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
- **Queue:** Redis 7 + BullMQ - **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches; ProRes, H.264, HEVC,
- **Storage:** S3-compatible (RustFS) DNxHR, MOV/MP4/MXF containers
- **Media Processing:** FFmpeg - **Storage:** S3-compatible (RustFS) for masters + proxies + thumbnails
- **Capture:** Blackmagic DeckLink SDK
- **Deployment:** Docker Compose ## 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 ## 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 [schedules, setSchedules] = React.useState(null);
const [recorders, setRecorders] = React.useState([]); const [recorders, setRecorders] = React.useState([]);
const [showNew, setShowNew] = React.useState(false); const [showNew, setShowNew] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const [filter, setFilter] = React.useState('upcoming'); const [filter, setFilter] = React.useState('upcoming');
const load = React.useCallback(() => { 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 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><span className={`badge ${badge.cls}`}>{badge.label}</span></div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}> <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') && ( {(s.status === 'pending' || s.status === 'running') && (
<button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button> <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
)} )}
@ -758,6 +762,96 @@ function Schedule({ navigate }) {
</div> </div>
{showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />} {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> </div>
); );
} }