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:
parent
7700548dee
commit
7170a9945c
2 changed files with 170 additions and 32 deletions
108
README.md
108
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
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue