Compare commits
7 commits
main
...
feat/hls-v
| Author | SHA1 | Date | |
|---|---|---|---|
| 01a19c0d69 | |||
| 39e010544c | |||
| d58982ad18 | |||
| a1b8211ea1 | |||
| ac5a667e65 | |||
| 1ca295d799 | |||
| be819353a7 |
22 changed files with 613 additions and 451 deletions
|
|
@ -1,221 +0,0 @@
|
||||||
# Unified Framecache — Implementation Plan
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Replace the current named-FIFO-per-source architecture with a shared-memory
|
|
||||||
ring buffer (framecache) that fans raw video frames from any ingest source to
|
|
||||||
unlimited concurrent consumers with zero-copy reads.
|
|
||||||
|
|
||||||
**Approved design:** docs/design/framecache/DESIGN.md
|
|
||||||
**Branch:** feat/unified-framecache
|
|
||||||
**Roadmap (out of scope here):** RDMA cross-node, AJA, growing-file-while-recording browser playback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Strategy
|
|
||||||
|
|
||||||
Ship in 5 phases. Each phase is independently deployable and leaves the system
|
|
||||||
in a working state. Existing recording workflows are unaffected until Phase 5
|
|
||||||
cuts over.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — Framecache Container (foundation)
|
|
||||||
|
|
||||||
**Goal:** Running framecache service with slot registry. No ingest writers yet.
|
|
||||||
|
|
||||||
### 1.1 — Create `services/framecache/` directory structure
|
|
||||||
|
|
||||||
```
|
|
||||||
services/framecache/
|
|
||||||
src/
|
|
||||||
framecache.c # main — slot manager + HTTP API
|
|
||||||
slot.c / slot.h # shm ring buffer lifecycle
|
|
||||||
registry.c # /dev/shm/framecache/registry.json writer
|
|
||||||
http.c # lightweight HTTP server (libmicrohttpd)
|
|
||||||
client/
|
|
||||||
fc_client.c / fc_client.h # consumer library
|
|
||||||
fc_client_node/
|
|
||||||
binding.cc # Node.js N-API addon
|
|
||||||
binding.gyp
|
|
||||||
Dockerfile
|
|
||||||
CMakeLists.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 — Shared memory layout (slot.h)
|
|
||||||
|
|
||||||
Each slot lives at `/dev/shm/framecache/<slot_id>`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#define FC_MAGIC 0x46524D43 // "FRMC"
|
|
||||||
#define FC_RING_DEPTH 120 // ~2s at 59.94fps
|
|
||||||
#define FC_HEADER_SIZE 4096 // 4KB header block
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint32_t magic;
|
|
||||||
uint32_t version; // = 1
|
|
||||||
uint32_t width;
|
|
||||||
uint32_t height;
|
|
||||||
uint32_t fps_num;
|
|
||||||
uint32_t fps_den;
|
|
||||||
uint32_t pixel_format; // FC_PIX_UYVY422 = 0
|
|
||||||
uint32_t frame_size; // width * height * 2
|
|
||||||
uint32_t ring_depth; // = FC_RING_DEPTH
|
|
||||||
_Atomic uint64_t write_cursor; // monotonically increasing frame index
|
|
||||||
_Atomic uint64_t dropped_frames;
|
|
||||||
uint8_t _pad[FC_HEADER_SIZE - 48];
|
|
||||||
} fc_header_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint64_t pts_us;
|
|
||||||
uint64_t wall_us;
|
|
||||||
uint32_t size;
|
|
||||||
uint8_t data[]; // frame_size bytes
|
|
||||||
} fc_frame_t;
|
|
||||||
```
|
|
||||||
|
|
||||||
Semaphore: `sem_open("/framecache-<slot_id>-write", ...)` — posted by writer
|
|
||||||
on each new frame, consumers `sem_timedwait` on it.
|
|
||||||
|
|
||||||
### 1.3 — HTTP API (port 7435)
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /slots body: {slot_id, width, height, fps_num, fps_den, source_type}
|
|
||||||
creates shm region, writes registry entry
|
|
||||||
201 {slot_id, shm_path, sem_name}
|
|
||||||
|
|
||||||
GET /slots 200 [{slot_id, width, height, fps_num, fps_den,
|
|
||||||
source_type, write_cursor, dropped_frames,
|
|
||||||
current_fps}]
|
|
||||||
|
|
||||||
GET /slots/:id 200 slot detail
|
|
||||||
DELETE /slots/:id destroys shm + semaphore, removes registry entry, 204
|
|
||||||
GET /health 200 {status: "ok"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 — Registry file
|
|
||||||
|
|
||||||
Written to `/dev/shm/framecache/registry.json` on every slot create/delete.
|
|
||||||
|
|
||||||
### 1.5 — Dockerfile
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM debian:bookworm
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
build-essential cmake libmicrohttpd-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY . /src
|
|
||||||
RUN cmake -S /src -B /build -DCMAKE_BUILD_TYPE=Release \
|
|
||||||
&& cmake --build /build -j$(nproc)
|
|
||||||
EXPOSE 7435
|
|
||||||
CMD ["/build/framecache"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.6 — docker-compose.worker.yml addition
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
framecache:
|
|
||||||
build: ./services/framecache
|
|
||||||
ipc: host
|
|
||||||
shm_size: '60gb'
|
|
||||||
environment:
|
|
||||||
FC_SHM_SIZE: ${FC_SHM_SIZE:-64424509440}
|
|
||||||
FC_PORT: 7435
|
|
||||||
ports:
|
|
||||||
- "7435:7435"
|
|
||||||
volumes:
|
|
||||||
- /dev/shm:/dev/shm
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.7 — Consumer library (fc_client.c)
|
|
||||||
|
|
||||||
```c
|
|
||||||
fc_slot_t *fc_open(const char *slot_id);
|
|
||||||
int fc_read_frame(fc_slot_t *slot, fc_frame_t **out, uint64_t timeout_ms);
|
|
||||||
void fc_close(fc_slot_t *slot);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Commit:** `feat(framecache): phase 1 — framecache container + consumer library`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Deltacast Bridge writes to framecache
|
|
||||||
|
|
||||||
**Goal:** deltacast-bridge writes frames to framecache shm instead of named FIFOs.
|
|
||||||
Legacy FIFO path kept as compile-time fallback (`-DLEGACY_FIFO=ON`) until Phase 5.
|
|
||||||
|
|
||||||
On signal lock:
|
|
||||||
1. POST /slots to framecache HTTP API
|
|
||||||
2. shm_open + mmap the slot
|
|
||||||
3. Video thread writes frame into ring, advances write_cursor atomically, sem_post
|
|
||||||
4. Audio: keeps writing to audio FIFO (unchanged)
|
|
||||||
5. On shutdown: DELETE /slots/:id
|
|
||||||
|
|
||||||
**Commit:** `feat(framecache): phase 2 — deltacast-bridge writes to shm`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — Blackmagic DeckLink Bridge
|
|
||||||
|
|
||||||
**Goal:** New decklink-bridge C program mirrors deltacast-bridge, replaces
|
|
||||||
ffmpeg -f decklink direct path.
|
|
||||||
|
|
||||||
- Uses IDeckLinkIterator to enumerate devices
|
|
||||||
- VideoInputFrameArrived callback calls fc_write_frame
|
|
||||||
- Registers slot on signal lock, deregisters on shutdown
|
|
||||||
- Audio stays in FIFO (same as deltacast)
|
|
||||||
|
|
||||||
**Commit:** `feat(framecache): phase 3 — decklink-bridge writes to shm`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 — capture-manager reads from framecache
|
|
||||||
|
|
||||||
**Goal:** Enables simultaneous growing + proxy + HLS from one SDI input.
|
|
||||||
|
|
||||||
- Node.js N-API addon wrapping fc_open/fc_read_frame/fc_close
|
|
||||||
- capture-manager opens THREE fc_client handles per slot (own cursor each):
|
|
||||||
1. Growing/master ffmpeg feed
|
|
||||||
2. Proxy ffmpeg feed
|
|
||||||
3. HLS preview ffmpeg feed
|
|
||||||
- Each gets a separate rawvideo pipe to ffmpeg
|
|
||||||
- Growing MXF workflow (raw2bmx orchestrator) completely unchanged
|
|
||||||
|
|
||||||
**Commit:** `feat(framecache): phase 4 — capture-manager reads from framecache`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5 — Network ingest (RTMP/SRT) into framecache
|
|
||||||
|
|
||||||
**Goal:** RTMP and SRT sources decoded to raw UYVY422, written into framecache slots.
|
|
||||||
|
|
||||||
- net_ingest process per source: ffmpeg decodes to rawvideo, writes to slot
|
|
||||||
- capture-manager waits for slot, same fc_client consumer pattern
|
|
||||||
- Remove legacy FIFO code once all paths go through framecache
|
|
||||||
|
|
||||||
**Commit:** `feat(framecache): phase 5 — network ingest via framecache`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hardware / Deployment
|
|
||||||
|
|
||||||
| Node | RAM | /dev/shm | FC_SHM_SIZE |
|
|
||||||
|------|-----|----------|-------------|
|
|
||||||
| Baratheon | 251GB | 126GB | 60GB |
|
|
||||||
| zampp1 | 93GB | 47GB | 40GB |
|
|
||||||
| zampp2 | 18GB (upgrade) | 9.4GB | 8GB |
|
|
||||||
|
|
||||||
Ring buffer per 1080p59.94 source: ~494MB (120 frames × 4.1MB)
|
|
||||||
|
|
||||||
All recorder sidecars require `ipc: host`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap (not in this branch)
|
|
||||||
|
|
||||||
- Audio in framecache shm
|
|
||||||
- RDMA cross-node slot replication
|
|
||||||
- AJA hardware support
|
|
||||||
- Growing-file-while-recording browser HLS playback
|
|
||||||
- Mastercontrol/playout consumer
|
|
||||||
|
|
@ -1091,13 +1091,12 @@ exit "$BMXRC"
|
||||||
if (m) {
|
if (m) {
|
||||||
this.state.framesReceived = parseInt(m[1], 10);
|
this.state.framesReceived = parseInt(m[1], 10);
|
||||||
this.state.lastFrameAt = new Date().toISOString();
|
this.state.lastFrameAt = new Date().toISOString();
|
||||||
// Use ffmpeg's own rolling fps value — it is a short-window average
|
if (this.state.recordingStartedAt) {
|
||||||
// computed by ffmpeg itself and correctly reflects the true encode rate.
|
const elapsedSec = (Date.now() - this.state.recordingStartedAt) / 1000;
|
||||||
// The previous frame/elapsed cumulative calculation dragged low during
|
if (elapsedSec > 0) {
|
||||||
// startup and was permanently wrong for growing-path (bash orchestrator
|
this.state.currentFps = Math.round((this.state.framesReceived / elapsedSec) * 100) / 100;
|
||||||
// stderr doesn't emit frame= lines until ffmpeg flushes them).
|
}
|
||||||
const ffmpegFps = parseFloat(m[2]);
|
}
|
||||||
if (ffmpegFps > 0) this.state.currentFps = Math.round(ffmpegFps * 100) / 100;
|
|
||||||
}
|
}
|
||||||
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
|
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);
|
this.state.lastError = text.trim().slice(0, 240);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
import { randomBytes, createHash } from 'node:crypto';
|
||||||
|
|
||||||
const PREFIX = 'dfl_';
|
const PREFIX = 'dfl_';
|
||||||
|
|
||||||
|
|
@ -10,14 +10,6 @@ export function hashToken(token) {
|
||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compareTokens(tokenA, tokenB) {
|
|
||||||
if (!tokenA || !tokenB) return false;
|
|
||||||
const a = Buffer.from(tokenA);
|
|
||||||
const b = Buffer.from(tokenB);
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
return timingSafeEqual(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseBearer(authorizationHeader) {
|
export function parseBearer(authorizationHeader) {
|
||||||
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
|
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
|
||||||
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
|
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
// Protected by requireAuth — AMPP Script Task must use an API token (Bearer Auth).
|
// No session auth — called from AMPP Script Task inside broadcast network
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/ampp/folder-for/:filename
|
* GET /api/v1/ampp/folder-for/:filename
|
||||||
|
|
@ -15,7 +14,7 @@ const router = express.Router();
|
||||||
* 200: { folder_id: "abc123" }
|
* 200: { folder_id: "abc123" }
|
||||||
* 404: { error: "..." } (file not uploaded through Dragon-Wind — handle gracefully)
|
* 404: { error: "..." } (file not uploaded through Dragon-Wind — handle gracefully)
|
||||||
*/
|
*/
|
||||||
router.get('/folder-for/:filename', requireAuth, async (req, res, next) => {
|
router.get('/folder-for/:filename', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { filename } = req.params;
|
const { filename } = req.params;
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|
|
||||||
|
|
@ -767,7 +767,7 @@ router.get('/:id/stream', async (req, res, next) => {
|
||||||
if (a.hls_s3_key) {
|
if (a.hls_s3_key) {
|
||||||
return res.json({
|
return res.json({
|
||||||
url: `/api/v1/assets/${id}/video`,
|
url: `/api/v1/assets/${id}/video`,
|
||||||
type: 'mp4',
|
type: 'hls',
|
||||||
source: a.proxy_s3_key ? 'proxy' : 'original',
|
source: a.proxy_s3_key ? 'proxy' : 'original',
|
||||||
hls_url: `/api/v1/assets/${id}/hls/playlist.m3u8`,
|
hls_url: `/api/v1/assets/${id}/hls/playlist.m3u8`,
|
||||||
});
|
});
|
||||||
|
|
@ -858,9 +858,65 @@ router.get('/:id/live-path', async (req, res, next) => {
|
||||||
// - ETag + Last-Modified for conditional requests (304 on repeat visits)
|
// - ETag + Last-Modified for conditional requests (304 on repeat visits)
|
||||||
// - Cache-Control: private, max-age=3600 so the browser caches segments
|
// - Cache-Control: private, max-age=3600 so the browser caches segments
|
||||||
// and doesn't re-fetch them on every seek within a session
|
// and doesn't re-fetch them on every seek within a session
|
||||||
|
// Issue #143 — RustFS returns empty bodies for ranged GETs whose start offset
|
||||||
|
// is past ~5.9 MB on single-file proxy MP4s. Confirmed via direct S3 probe:
|
||||||
|
// HEAD reports correct size, full GET (`bytes=0-`) works perfectly, but
|
||||||
|
// `bytes=8179166-` returns 206 + the right Content-Range header and a zero-
|
||||||
|
// byte body. A streaming GET from 0 reads cleanly *through* the broken zone.
|
||||||
//
|
//
|
||||||
// RustFS issue #143 (empty body on ranged GETs past ~5.9 MB) was fixed in
|
// Workaround until the proxy worker emits HLS (planned v1.2.1): stream the
|
||||||
// RustFS 1.0.0-alpha.94 (PR #2493). Standard ranged GETs used throughout.
|
// proxy from offset 0, skip bytes the client didn't ask for, stop after the
|
||||||
|
// requested end. Browser sees a normal 206 + Content-Range. Mem stays flat;
|
||||||
|
// extra RustFS-to-mam-api bandwidth = (end+1 - actual-range) per seek.
|
||||||
|
//
|
||||||
|
// Small head-of-file ranges below RUSTFS_RANGE_SAFE_START are handled by a
|
||||||
|
// direct ranged GET — saves the streaming-from-0 cost on the common case of
|
||||||
|
// initial moov + first-segment fetch.
|
||||||
|
|
||||||
|
async function* stitchedS3Stream(key, startByte, endByte) {
|
||||||
|
// Yields buffers covering exactly [startByte, endByte] inclusive.
|
||||||
|
//
|
||||||
|
// RustFS only mis-serves a ranged GET when the *start* offset of the
|
||||||
|
// request is past ~5.8 MB. So we pull the object in 4 MB windows whose
|
||||||
|
// START offsets always stay below the broken threshold:
|
||||||
|
// - We anchor every chunk's start at a multiple of RUSTFS_SAFE_CHUNK
|
||||||
|
// (0, 4 MB, 8 MB, …).
|
||||||
|
// - Wait — that puts later starts past the threshold.
|
||||||
|
// Instead: skip directly to the chunk containing `startByte`, but request
|
||||||
|
// it as `bytes=anchorStart-end` where anchorStart < threshold. Since the
|
||||||
|
// bug only bites when the *request start* offset is large, we never issue
|
||||||
|
// a single GET whose Range start is past the broken zone — we instead
|
||||||
|
// exploit that a low-offset GET that *continues past* the threshold reads
|
||||||
|
// cleanly (confirmed by the bytes=0- full-GET probe).
|
||||||
|
//
|
||||||
|
// Practically: one GET from 0 that streams up through endByte, dropping
|
||||||
|
// the bytes below startByte as they arrive. Memory stays flat; we pay
|
||||||
|
// (endByte+1) bytes of RustFS-to-mam-api bandwidth per request.
|
||||||
|
const res = await s3Client.send(new GetObjectCommand({
|
||||||
|
Bucket: getS3Bucket(),
|
||||||
|
Key: key,
|
||||||
|
Range: `bytes=0-${endByte}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let consumed = 0; // bytes seen so far from S3
|
||||||
|
let totalEmitted = 0;
|
||||||
|
for await (const buf of res.Body) {
|
||||||
|
const bufStart = consumed; // file offset of buf[0]
|
||||||
|
const bufEnd = consumed + buf.length - 1;
|
||||||
|
consumed += buf.length;
|
||||||
|
if (bufEnd < startByte) continue; // entirely before window
|
||||||
|
const sliceFrom = Math.max(0, startByte - bufStart);
|
||||||
|
const sliceTo = Math.min(buf.length, endByte - bufStart + 1);
|
||||||
|
if (sliceTo > sliceFrom) {
|
||||||
|
yield buf.subarray(sliceFrom, sliceTo);
|
||||||
|
totalEmitted += sliceTo - sliceFrom;
|
||||||
|
}
|
||||||
|
if (bufEnd >= endByte) break;
|
||||||
|
}
|
||||||
|
if (totalEmitted === 0) {
|
||||||
|
throw new Error(`RustFS returned empty body for ${key} bytes=0-${endByte}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/:id/video', async (req, res, next) => {
|
router.get('/:id/video', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -941,11 +997,39 @@ router.get('/:id/video', async (req, res, next) => {
|
||||||
if (etag) headers['ETag'] = etag;
|
if (etag) headers['ETag'] = etag;
|
||||||
if (lastModified) headers['Last-Modified'] = lastModified.toUTCString();
|
if (lastModified) headers['Last-Modified'] = lastModified.toUTCString();
|
||||||
|
|
||||||
|
// For small head-of-file ranges (entirely below the broken threshold)
|
||||||
|
// a direct ranged GET works and saves the streaming-from-0 cost.
|
||||||
|
const RUSTFS_RANGE_SAFE_START = parseInt(process.env.RUSTFS_RANGE_SAFE_START || String(5_500_000), 10);
|
||||||
|
if (start < RUSTFS_RANGE_SAFE_START && end < RUSTFS_RANGE_SAFE_START) {
|
||||||
const s3Res = await s3Client.send(new GetObjectCommand({
|
const s3Res = await s3Client.send(new GetObjectCommand({
|
||||||
Bucket: getS3Bucket(), Key: key, Range: `bytes=${start}-${end}`,
|
Bucket: getS3Bucket(), Key: key, Range: `bytes=${start}-${end}`,
|
||||||
}));
|
}));
|
||||||
res.writeHead(206, headers);
|
res.writeHead(206, headers);
|
||||||
s3Res.Body.pipe(res);
|
s3Res.Body.pipe(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise: stream from offset 0, drop bytes below `start`, stop at
|
||||||
|
// `end`. Browser sees a normal 206; mam-api stays memory-flat.
|
||||||
|
res.writeHead(206, headers);
|
||||||
|
try {
|
||||||
|
for await (const buf of stitchedS3Stream(key, start, end)) {
|
||||||
|
// res.write returns false when backpressure builds — pause and wait.
|
||||||
|
if (!res.write(buf)) {
|
||||||
|
await new Promise(r => res.once('drain', r));
|
||||||
|
}
|
||||||
|
if (res.destroyed) return;
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[video] stitch failed for ${key}:`, err.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
||||||
|
res.end('Upstream storage error');
|
||||||
|
} else {
|
||||||
|
res.destroy(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,30 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { createReadStream, existsSync } from 'fs';
|
||||||
|
import { stat } from 'fs/promises';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
import dgram from 'dgram';
|
import dgram from 'dgram';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { getS3Bucket } from '../s3/client.js';
|
import { s3Client, getS3Bucket } from '../s3/client.js';
|
||||||
|
import { Upload } from '@aws-sdk/lib-storage';
|
||||||
import { validateUuid } from '../middleware/errors.js';
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// BullMQ proxy queue — used by the growing-file stop handler to queue proxy
|
||||||
|
// jobs when the capture container's finalize call races with the S3 upload.
|
||||||
|
const parseRedisUrl = (url) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
|
||||||
|
};
|
||||||
|
const proxyQueue = new Queue('proxy', {
|
||||||
|
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||||
|
});
|
||||||
|
|
||||||
// Every /:id recorder route is scoped to the recorder's project. The param
|
// Every /:id recorder route is scoped to the recorder's project. The param
|
||||||
// handler validates the UUID, resolves the owning project_id, and asserts the
|
// handler validates the UUID, resolves the owning project_id, and asserts the
|
||||||
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
|
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
|
||||||
|
|
@ -40,7 +54,7 @@ async function requireRecorderEdit(req, res, next) {
|
||||||
const SIDECAR_PORT_BASE = 7438;
|
const SIDECAR_PORT_BASE = 7438;
|
||||||
|
|
||||||
// Docker API helper function
|
// Docker API helper function
|
||||||
function dockerApi(method, path, body = null, timeoutMs = 10000) {
|
function dockerApi(method, path, body = null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const options = {
|
const options = {
|
||||||
socketPath: '/var/run/docker.sock',
|
socketPath: '/var/run/docker.sock',
|
||||||
|
|
@ -60,9 +74,9 @@ function dockerApi(method, path, body = null, timeoutMs = 10000) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
// Use parameterizable timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
||||||
req.setTimeout(timeoutMs, () => {
|
req.setTimeout(10000, () => {
|
||||||
req.destroy(new Error(`Docker API timeout after ${timeoutMs/1000}s`));
|
req.destroy(new Error('Docker API timeout after 10s'));
|
||||||
});
|
});
|
||||||
if (body) req.write(JSON.stringify(body));
|
if (body) req.write(JSON.stringify(body));
|
||||||
req.end();
|
req.end();
|
||||||
|
|
@ -796,20 +810,39 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
const containerId = recorder.container_id;
|
const containerId = recorder.container_id;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`, null, 185000);
|
const stopRes = await dockerApi('POST', `/containers/${containerId}/stop?t=180`);
|
||||||
if (stopRes.status !== 404) {
|
if (stopRes.status !== 404) {
|
||||||
await waitForFinalize(recorder);
|
await waitForFinalize(recorder);
|
||||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[recorders] failed local background stop:', e.message);
|
console.error('[recorders] failed local background stop:', e.message);
|
||||||
// Attempt finalize and cleanup even if stop call timed out
|
|
||||||
await waitForFinalize(recorder).catch(() => {});
|
|
||||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Growing-files S3 promotion ────────────────────────────────────────────
|
||||||
|
// When growing_enabled=true the capture container writes the master file to
|
||||||
|
// /growing/{projectId}/{clipName}.{ext} (a host bind-mount that the mam-api
|
||||||
|
// container also has at /growing). The capture container's graceful-shutdown
|
||||||
|
// handler (triggered by the Docker stop above) calls POST /assets/:id/finalize
|
||||||
|
// with the expected S3 key, which queues the proxy job — but the file was
|
||||||
|
// never uploaded to S3, so the proxy worker fails with "unable to open file".
|
||||||
|
//
|
||||||
|
// Fix: after the container has exited (ffmpeg is done flushing), upload the
|
||||||
|
// growing file to the canonical S3 key from here. This is synchronous and
|
||||||
|
// completes before the HTTP response reaches the client, so the already-queued
|
||||||
|
// proxy job will find a valid S3 object when the worker dequeues it.
|
||||||
|
//
|
||||||
|
// Only applies to LOCAL recorders — remote recorders write to a different
|
||||||
|
// node's /growing mount which this process cannot access.
|
||||||
|
if (!isRemote && recorder.growing_enabled === true && recorder.current_session_id) {
|
||||||
|
await promoteGrowingFileToS3(recorder).catch(err => {
|
||||||
|
// Non-fatal — log and continue so the stop always succeeds.
|
||||||
|
console.error('[recorders/stop] growing-file promotion failed (non-fatal):', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updateResult = await pool.query(
|
const updateResult = await pool.query(
|
||||||
`UPDATE recorders
|
`UPDATE recorders
|
||||||
SET container_id = NULL, status = $1, updated_at = NOW()
|
SET container_id = NULL, status = $1, updated_at = NOW()
|
||||||
|
|
@ -824,6 +857,109 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a completed growing-file master from /growing to S3 so the proxy
|
||||||
|
* worker can find it at the expected original_s3_key.
|
||||||
|
*
|
||||||
|
* The capture container writes to:
|
||||||
|
* /growing/{projectId}/{clipName}.{ext}
|
||||||
|
*
|
||||||
|
* The canonical S3 key (set on the asset row at recording start) is:
|
||||||
|
* projects/{projectId}/masters/{clipName}.{ext}
|
||||||
|
*
|
||||||
|
* We look up the live/processing asset to derive both paths, do a multipart
|
||||||
|
* upload, update the asset's original_s3_key and file_size to match what we
|
||||||
|
* actually uploaded, then ensure a proxy job exists for it.
|
||||||
|
*/
|
||||||
|
async function promoteGrowingFileToS3(recorder) {
|
||||||
|
const clipName = recorder.current_session_id;
|
||||||
|
const container = recorder.recording_container || 'mov';
|
||||||
|
|
||||||
|
// Find the asset that was pre-created at recording start. It could be in
|
||||||
|
// 'live' (finalize hasn't fired yet) or 'processing' (finalize already ran
|
||||||
|
// from the container's SIGTERM handler). We need both its id and its
|
||||||
|
// project_id to reconstruct the growing path.
|
||||||
|
const assetRes = await pool.query(
|
||||||
|
`SELECT id, project_id, status, original_s3_key
|
||||||
|
FROM assets
|
||||||
|
WHERE display_name = $1
|
||||||
|
AND status IN ('live', 'processing', 'error')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[clipName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (assetRes.rows.length === 0) {
|
||||||
|
console.warn(`[recorders/stop] no asset found for clip "${clipName}" — skipping growing-file promotion`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = assetRes.rows[0];
|
||||||
|
const projectId = asset.project_id;
|
||||||
|
const growingDir = process.env.GROWING_DIR || '/growing';
|
||||||
|
const localPath = `${growingDir}/${projectId}/${clipName}.${container}`;
|
||||||
|
const s3Key = `projects/${projectId}/masters/${clipName}.${container}`;
|
||||||
|
|
||||||
|
if (!existsSync(localPath)) {
|
||||||
|
console.warn(`[recorders/stop] growing file not found at ${localPath} — nothing to promote (empty recording?)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStat = await stat(localPath);
|
||||||
|
if (fileStat.size === 0) {
|
||||||
|
console.warn(`[recorders/stop] growing file at ${localPath} is empty — skipping promotion`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[recorders/stop] promoting growing file ${localPath} (${fileStat.size} bytes) → s3://${getS3Bucket()}/${s3Key}`);
|
||||||
|
|
||||||
|
const upload = new Upload({
|
||||||
|
client: s3Client,
|
||||||
|
params: {
|
||||||
|
Bucket: getS3Bucket(),
|
||||||
|
Key: s3Key,
|
||||||
|
Body: createReadStream(localPath),
|
||||||
|
},
|
||||||
|
queueSize: 4,
|
||||||
|
partSize: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
await upload.done();
|
||||||
|
|
||||||
|
console.log(`[recorders/stop] S3 upload complete for ${s3Key}`);
|
||||||
|
|
||||||
|
// Ensure the asset row reflects the correct S3 key and file size. The
|
||||||
|
// capture container's finalize call may have already set original_s3_key to
|
||||||
|
// this same value (it was pre-set at start), but update file_size which
|
||||||
|
// finalize doesn't touch.
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE assets
|
||||||
|
SET original_s3_key = $1,
|
||||||
|
file_size = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3`,
|
||||||
|
[s3Key, fileStat.size, asset.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the asset is still 'live' (capture container's finalize hasn't fired or
|
||||||
|
// failed), flip it to 'processing' and queue the proxy job ourselves so the
|
||||||
|
// clip doesn't get stuck in the library as "Recording…".
|
||||||
|
if (asset.status === 'live') {
|
||||||
|
console.log(`[recorders/stop] finalize not yet called — queueing proxy and flipping asset ${asset.id} to processing`);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[asset.id]
|
||||||
|
);
|
||||||
|
await proxyQueue.add('generate', {
|
||||||
|
assetId: asset.id,
|
||||||
|
inputKey: s3Key,
|
||||||
|
outputKey: `proxies/${asset.id}.mp4`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If status is already 'processing', the capture container's finalize already
|
||||||
|
// ran and queued the proxy job. The S3 upload we just did ensures the worker
|
||||||
|
// will find a valid object when it dequeues that job — nothing else to do.
|
||||||
|
}
|
||||||
|
|
||||||
// GET /:id/status - Get live status
|
// GET /:id/status - Get live status
|
||||||
router.get('/:id/status', async (req, res, next) => {
|
router.get('/:id/status', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -993,11 +1129,10 @@ router.post('/probe', async (req, res) => {
|
||||||
|
|
||||||
// Validate URL up-front so we don't even let the capture service see junk.
|
// Validate URL up-front so we don't even let the capture service see junk.
|
||||||
let parsed = null;
|
let parsed = null;
|
||||||
let proto = '';
|
|
||||||
if (url) {
|
if (url) {
|
||||||
try { parsed = new URL(url); }
|
try { parsed = new URL(url); }
|
||||||
catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
catch { return res.status(400).json({ error: 'Invalid URL' }); }
|
||||||
proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||||
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
|
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
|
||||||
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
|
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
|
||||||
}
|
}
|
||||||
|
|
@ -1005,11 +1140,6 @@ router.post('/probe', async (req, res) => {
|
||||||
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
|
||||||
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
|
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe target should not be mam-api itself.
|
|
||||||
if (parsed.hostname === 'mam-api' || parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
|
|
||||||
return res.status(403).json({ error: 'Internal probe target is not permitted' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try the capture service first (5s timeout)
|
// Try the capture service first (5s timeout)
|
||||||
|
|
@ -1035,6 +1165,7 @@ router.post('/probe', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = parsed.hostname;
|
const host = parsed.hostname;
|
||||||
|
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
|
||||||
const isUdp = proto === 'srt' || source_type === 'srt';
|
const isUdp = proto === 'srt' || source_type === 'srt';
|
||||||
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
|
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ async function probeGrowingPath(path) {
|
||||||
|
|
||||||
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
|
// df -P returns POSIX-portable output: "Filesystem 1024-blocks Used Available Capacity Mounted-on"
|
||||||
try {
|
try {
|
||||||
const { stdout } = await exec(`df -PB1 -- ${JSON.stringify(path)}`, { timeout: 3000 });
|
const { stdout } = await exec(`df -PB1 ${JSON.stringify(path)}`, { timeout: 3000 });
|
||||||
const lines = stdout.trim().split('\n');
|
const lines = stdout.trim().split('\n');
|
||||||
if (lines.length >= 2) {
|
if (lines.length >= 2) {
|
||||||
const cols = lines[1].split(/\s+/);
|
const cols = lines[1].split(/\s+/);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ router.post('/', async (req, res, next) => {
|
||||||
`INSERT INTO users (username, password_hash, display_name, role)
|
`INSERT INTO users (username, password_hash, display_name, role)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, username, display_name, role, created_at`,
|
RETURNING id, username, display_name, role, created_at`,
|
||||||
[username.trim(), hash, display_name || username.trim(), role || 'viewer']
|
[username.trim(), hash, display_name || username.trim(), role || 'admin']
|
||||||
);
|
);
|
||||||
res.status(201).json(rows[0]);
|
res.status(201).json(rows[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,7 @@ async function tick() {
|
||||||
|
|
||||||
// Orphaned live assets: recorder stopped but asset still 'live'.
|
// Orphaned live assets: recorder stopped but asset still 'live'.
|
||||||
// Happens when the capture sidecar crashes before finalize() runs.
|
// Happens when the capture sidecar crashes before finalize() runs.
|
||||||
// Grace window is measured from when the RECORDER was last updated
|
// Mark error immediately so the library doesn't show "Recording" forever.
|
||||||
// (i.e. when it transitioned to stopped), not from asset creation.
|
|
||||||
// This prevents a race where the scheduler fires before the capture
|
|
||||||
// container's finalize POST lands (can take 30-60s on large files).
|
|
||||||
const ORPHAN_GRACE_SECONDS = parseInt(process.env.ORPHAN_GRACE_SECONDS || '120', 10);
|
|
||||||
const orphanResult = await client.query(
|
const orphanResult = await client.query(
|
||||||
`UPDATE assets a
|
`UPDATE assets a
|
||||||
SET status = 'error', updated_at = NOW()
|
SET status = 'error', updated_at = NOW()
|
||||||
|
|
@ -149,9 +145,7 @@ async function tick() {
|
||||||
WHERE a.status = 'live'
|
WHERE a.status = 'live'
|
||||||
AND a.display_name = r.current_session_id
|
AND a.display_name = r.current_session_id
|
||||||
AND r.status = 'stopped'
|
AND r.status = 'stopped'
|
||||||
AND r.updated_at < NOW() - ($1 || ' seconds')::INTERVAL
|
RETURNING a.id, a.display_name`
|
||||||
RETURNING a.id, a.display_name`,
|
|
||||||
[ORPHAN_GRACE_SECONDS]
|
|
||||||
);
|
);
|
||||||
if (orphanResult.rows.length > 0) {
|
if (orphanResult.rows.length > 0) {
|
||||||
for (const row of orphanResult.rows) {
|
for (const row of orphanResult.rows) {
|
||||||
|
|
|
||||||
|
|
@ -461,15 +461,7 @@ function checkAgentAuth(req) {
|
||||||
if (!NODE_TOKEN) return true;
|
if (!NODE_TOKEN) return true;
|
||||||
const hdr = req.headers['authorization'] || '';
|
const hdr = req.headers['authorization'] || '';
|
||||||
const m = /^Bearer\s+(.+)$/i.exec(hdr);
|
const m = /^Bearer\s+(.+)$/i.exec(hdr);
|
||||||
if (!m) return false;
|
return !!m && m[1] === NODE_TOKEN;
|
||||||
|
|
||||||
const token = m[1];
|
|
||||||
if (token.length !== NODE_TOKEN.length) return false;
|
|
||||||
try {
|
|
||||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(NODE_TOKEN));
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Driver/SDK install ────────────────────────────────────────────────────
|
// ── Driver/SDK install ────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
BIN
services/premiere-plugin-uxp/dragonflight-mam-2.2.3.ccx
Normal file
BIN
services/premiere-plugin-uxp/dragonflight-mam-2.2.3.ccx
Normal file
Binary file not shown.
|
|
@ -73,19 +73,7 @@ server {
|
||||||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
add_header Pragma "no-cache" always;
|
add_header Pragma "no-cache" always;
|
||||||
# Tighten CORS: no wildcard.
|
add_header Access-Control-Allow-Origin * always;
|
||||||
add_header Access-Control-Allow-Origin $http_origin always;
|
|
||||||
add_header Access-Control-Allow-Credentials "true" always;
|
|
||||||
if ($request_method = 'OPTIONS') {
|
|
||||||
add_header Access-Control-Allow-Origin $http_origin;
|
|
||||||
add_header Access-Control-Allow-Credentials "true";
|
|
||||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
|
|
||||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
|
||||||
add_header Access-Control-Max-Age 1728000;
|
|
||||||
add_header Content-Type 'text/plain; charset=utf-8';
|
|
||||||
add_header Content-Length 0;
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Playout HLS preview — CasparCG sidecar writes to the media volume under
|
# Playout HLS preview — CasparCG sidecar writes to the media volume under
|
||||||
|
|
@ -95,19 +83,7 @@ server {
|
||||||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
add_header Pragma "no-cache" always;
|
add_header Pragma "no-cache" always;
|
||||||
# Tighten CORS: no wildcard.
|
add_header Access-Control-Allow-Origin * always;
|
||||||
add_header Access-Control-Allow-Origin $http_origin always;
|
|
||||||
add_header Access-Control-Allow-Credentials "true" always;
|
|
||||||
if ($request_method = 'OPTIONS') {
|
|
||||||
add_header Access-Control-Allow-Origin $http_origin;
|
|
||||||
add_header Access-Control-Allow-Credentials "true";
|
|
||||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
|
|
||||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
|
||||||
add_header Access-Control-Max-Age 1728000;
|
|
||||||
add_header Content-Type 'text/plain; charset=utf-8';
|
|
||||||
add_header Content-Length 0;
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy - forward to mam-api service
|
# API proxy - forward to mam-api service
|
||||||
|
|
@ -157,11 +133,6 @@ server {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
expires -1;
|
expires -1;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:;" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deny access to dotfiles
|
# Deny access to dotfiles
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ function App() {
|
||||||
switch (effectiveRoute) {
|
switch (effectiveRoute) {
|
||||||
case 'home': content = <Home navigate={navigate} />; break;
|
case 'home': content = <Home navigate={navigate} />; break;
|
||||||
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
|
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
|
||||||
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} onOpenProject={openProjectFromAnywhere} />; break;
|
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
|
||||||
case 'projects': content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
|
case 'projects': content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
|
||||||
case 'upload': content = <Upload navigate={navigate} />; break;
|
case 'upload': content = <Upload navigate={navigate} />; break;
|
||||||
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
|
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
|
||||||
|
|
|
||||||
|
|
@ -243,11 +243,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/hires')
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/hires')
|
||||||
.then(function(r) {
|
.then(function(r) {
|
||||||
if (!r || !r.url) {
|
if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; }
|
||||||
if (window.toast) window.toast.error('No hi-res source available for this asset.');
|
|
||||||
else window.alert('No hi-res source available for this asset.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = r.url;
|
a.href = r.url;
|
||||||
a.download = r.filename || (asset.name + '.' + (r.ext || 'mov'));
|
a.download = r.filename || (asset.name + '.' + (r.ext || 'mov'));
|
||||||
|
|
@ -257,10 +253,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
})
|
})
|
||||||
.catch(function(e) {
|
.catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); })
|
||||||
if (window.toast) window.toast.error('Download failed: ' + (e.message || 'unknown error'));
|
|
||||||
else window.alert('Download failed: ' + (e.message || 'unknown error'));
|
|
||||||
})
|
|
||||||
.finally(function() { setDownloading(false); });
|
.finally(function() { setDownloading(false); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -286,10 +279,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
}))) return;
|
}))) return;
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
|
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
|
||||||
.then(function() { onClose && onClose(); })
|
.then(function() { onClose && onClose(); })
|
||||||
.catch(function(e) {
|
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
|
||||||
if (window.toast) window.toast.error('Delete failed: ' + e.message);
|
|
||||||
else window.alert('Delete failed: ' + e.message);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryProcessing = function() {
|
const retryProcessing = function() {
|
||||||
|
|
@ -297,13 +287,9 @@ function AssetDetail({ asset, onClose }) {
|
||||||
setRetrying(true);
|
setRetrying(true);
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
|
||||||
.then(function() {
|
.then(function() {
|
||||||
if (window.toast) window.toast.success('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
|
window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
|
||||||
else window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
|
|
||||||
})
|
|
||||||
.catch(function(e) {
|
|
||||||
if (window.toast) window.toast.error('Retry failed: ' + (e.message || 'unknown error'));
|
|
||||||
else window.alert('Retry failed: ' + (e.message || 'unknown error'));
|
|
||||||
})
|
})
|
||||||
|
.catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
|
||||||
.finally(function() { setRetrying(false); });
|
.finally(function() { setRetrying(false); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -312,26 +298,16 @@ function AssetDetail({ asset, onClose }) {
|
||||||
setReprocessing(type);
|
setReprocessing(type);
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
|
||||||
.then(function() {
|
.then(function() {
|
||||||
if (window.toast) window.toast.success((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
|
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
|
||||||
else window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
|
|
||||||
})
|
|
||||||
.catch(function(e) {
|
|
||||||
if (window.toast) window.toast.error('Reprocess failed: ' + (e.message || 'unknown error'));
|
|
||||||
else window.alert('Reprocess failed: ' + (e.message || 'unknown error'));
|
|
||||||
})
|
})
|
||||||
|
.catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); })
|
||||||
.finally(function() { setReprocessing(null); });
|
.finally(function() { setReprocessing(null); });
|
||||||
};
|
};
|
||||||
|
|
||||||
const regenFilmstrip = function() {
|
const regenFilmstrip = function() {
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
|
||||||
.then(function() {
|
.then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); })
|
||||||
if (window.toast) window.toast.success('Filmstrip job queued: it will appear automatically when ready.');
|
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
|
||||||
else window.alert('Filmstrip job queued: it will appear automatically when ready.');
|
|
||||||
})
|
|
||||||
.catch(function(e) {
|
|
||||||
if (window.toast) window.toast.error('Failed to queue filmstrip: ' + (e.message || 'unknown error'));
|
|
||||||
else window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error'));
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map a /assets/:id/comments row into the legacy shape the consumer
|
// Map a /assets/:id/comments row into the legacy shape the consumer
|
||||||
|
|
@ -376,8 +352,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
setComments(function(c) { return [...c, _normalizeComment(row)]; });
|
setComments(function(c) { return [...c, _normalizeComment(row)]; });
|
||||||
})
|
})
|
||||||
.catch(function(e) {
|
.catch(function(e) {
|
||||||
if (window.toast) window.toast.error('Could not post comment: ' + (e.message || 'unknown error'));
|
window.alert('Could not post comment: ' + (e.message || 'unknown error'));
|
||||||
else window.alert('Could not post comment: ' + (e.message || 'unknown error'));
|
|
||||||
setNewComment(text);
|
setNewComment(text);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -399,10 +374,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
.then(function() {
|
.then(function() {
|
||||||
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
|
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
|
||||||
})
|
})
|
||||||
.catch(function(e) {
|
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
|
||||||
if (window.toast) window.toast.error('Delete failed: ' + e.message);
|
|
||||||
else window.alert('Delete failed: ' + e.message);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });
|
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });
|
||||||
|
|
|
||||||
|
|
@ -306,7 +306,20 @@ function Editor() {
|
||||||
if (r && r.url) { url = r.url; cache[asset.id] = url; }
|
if (r && r.url) { url = r.url; cache[asset.id] = url; }
|
||||||
} catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); }
|
} catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); }
|
||||||
}
|
}
|
||||||
if (url) { vid.src = url; vid.load(); }
|
if (url) {
|
||||||
|
if (url.endsWith('.m3u8')) {
|
||||||
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
|
const hls = new window.Hls();
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(vid);
|
||||||
|
} else {
|
||||||
|
vid.src = url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vid.src = url;
|
||||||
|
}
|
||||||
|
vid.load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markSrcIn() {
|
function markSrcIn() {
|
||||||
|
|
@ -636,8 +649,14 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
||||||
if (vid) vid.pause();
|
if (vid) vid.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio track refs for playback
|
||||||
|
const pgmAudioRefs = React.useRef([]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) return;
|
if (!pgmPlaying || pgmClipIdx < 0 || pgmClipIdx >= pgmClips.length) {
|
||||||
|
pgmAudioRefs.current.forEach(a => a.pause());
|
||||||
|
return;
|
||||||
|
}
|
||||||
const clip = pgmClips[pgmClipIdx];
|
const clip = pgmClips[pgmClipIdx];
|
||||||
if (!clip) { stopPgm(); return; }
|
if (!clip) { stopPgm(); return; }
|
||||||
const vid = videoRef.current;
|
const vid = videoRef.current;
|
||||||
|
|
@ -651,7 +670,28 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
||||||
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
|
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
|
||||||
} catch (e) { window.DF_LOG.warn('[editor] stream fetch failed', e); return; }
|
} catch (e) { window.DF_LOG.warn('[editor] stream fetch failed', e); return; }
|
||||||
}
|
}
|
||||||
if (vid.src !== url) { vid.src = url; vid.load(); }
|
if (vid.src !== url) {
|
||||||
|
if (url.endsWith('.m3u8')) {
|
||||||
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
|
const hls = new window.Hls();
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(vid);
|
||||||
|
} else {
|
||||||
|
vid.src = url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vid.src = url;
|
||||||
|
}
|
||||||
|
vid.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync audio tracks (A1/A2)
|
||||||
|
const asset = assetsRef.current.find(a => a.id === clip.asset_id);
|
||||||
|
if (asset && asset.media_type === 'video') {
|
||||||
|
// For now, simple video-track audio. Multi-track A1/A2 wiring planned.
|
||||||
|
vid.muted = false;
|
||||||
|
}
|
||||||
|
|
||||||
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
|
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
|
||||||
vid.currentTime = srcInSecs;
|
vid.currentTime = srcInSecs;
|
||||||
vid.play().catch(() => {});
|
vid.play().catch(() => {});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
// screens-library.jsx
|
// screens-library.jsx
|
||||||
|
|
||||||
|
function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
function buildBinTree(f){const m={};f.forEach(b=>{m[b.id]={...b,children:[]};});const r=[];f.forEach(b=>{if(b.parent_id&&m[b.parent_id])m[b.parent_id].children.push(m[b.id]);else r.push(m[b.id]);});return r;}
|
|
||||||
function collectDescendantIds(id,f){const s=new Set([id]);let c=true;while(c){c=false;f.forEach(b=>{if(b.parent_id&&s.has(b.parent_id)&&!s.has(b.id)){s.add(b.id);c=true;}});}return s;}
|
|
||||||
function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenProject }) {
|
|
||||||
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
||||||
const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
|
const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
|
||||||
const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged
|
const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged
|
||||||
|
|
@ -17,8 +14,6 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
|
||||||
var normalized = (list || []).map(function(b) { return { ...b, count: b.asset_count != null ? b.asset_count : (b.count || 0), icon: b.type || 'grid' }; });
|
var normalized = (list || []).map(function(b) { return { ...b, count: b.asset_count != null ? b.asset_count : (b.count || 0), icon: b.type || 'grid' }; });
|
||||||
if (!openProject) window.ZAMPP_DATA.BINS = normalized;
|
if (!openProject) window.ZAMPP_DATA.BINS = normalized;
|
||||||
setBins(normalized);
|
setBins(normalized);
|
||||||
// Auto-expand all bins so nested children are always visible
|
|
||||||
setExpandedBins(function(prev) { var s = new Set(prev); normalized.forEach(function(b){ s.add(b.id); }); return s; });
|
|
||||||
})
|
})
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
}, [openProject]);
|
}, [openProject]);
|
||||||
|
|
@ -30,44 +25,21 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
|
||||||
return function() { window.removeEventListener('df:bins-changed', onBinsChanged); };
|
return function() { window.removeEventListener('df:bins-changed', onBinsChanged); };
|
||||||
}, [refreshBins]);
|
}, [refreshBins]);
|
||||||
|
|
||||||
const [creatingChildOf, setCreatingChildOf] = React.useState(null);
|
|
||||||
// Start with all bins expanded so nested children are visible immediately
|
|
||||||
const [expandedBins, setExpandedBins] = React.useState(() => new Set((window.ZAMPP_DATA?.BINS||[]).map(b=>b.id)));
|
|
||||||
|
|
||||||
const createBin = () => {
|
const createBin = () => {
|
||||||
if (!openProject) {
|
if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; }
|
||||||
if (window.toast) window.toast.error('Open a project first (Projects → click a project), then create a bin inside it.');
|
setNewBinName(''); setCreatingBin(true);
|
||||||
else window.alert('Open a project first (Projects → click a project), then create a bin inside it.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreatingChildOf(null); setNewBinName(''); setCreatingBin(true);
|
|
||||||
};
|
|
||||||
const createSubBin = (parentId) => {
|
|
||||||
if (!openProject) return;
|
|
||||||
setCreatingChildOf(parentId); setNewBinName(''); setCreatingBin(true);
|
|
||||||
};
|
|
||||||
const toggleBinExpanded = (binId) => {
|
|
||||||
setExpandedBins(prev => { const s = new Set(prev); s.has(binId) ? s.delete(binId) : s.add(binId); return s; });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitBin = (name) => {
|
const submitBin = (name) => {
|
||||||
if (!name || !name.trim()) { setCreatingBin(false); setCreatingChildOf(null); return; }
|
if (!name || !name.trim()) { setCreatingBin(false); return; }
|
||||||
setCreatingBin(false);
|
setCreatingBin(false);
|
||||||
const parentId = creatingChildOf;
|
|
||||||
setCreatingChildOf(null);
|
|
||||||
window.ZAMPP_API.fetch('/bins', {
|
window.ZAMPP_API.fetch('/bins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ project_id: openProject.id, name: name.trim(), parent_id: parentId || null }),
|
body: JSON.stringify({ project_id: openProject.id, name: name.trim() }),
|
||||||
})
|
})
|
||||||
.then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id))
|
.then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id))
|
||||||
.then(list => {
|
.then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' }))))
|
||||||
const n = (list||[]).map(b=>({...b,count:b.asset_count||0,icon:b.type||'grid'}));
|
.catch(e => window.alert('Could not create bin: ' + e.message));
|
||||||
setBins(n);
|
|
||||||
if (parentId) setExpandedBins(prev => { const s=new Set(prev); s.add(parentId); return s; });
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
if (window.toast) window.toast.error('Could not create bin: ' + e.message);
|
|
||||||
else window.alert('Could not create bin: ' + e.message);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const [view, setView] = React.useState('grid');
|
const [view, setView] = React.useState('grid');
|
||||||
const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent'
|
const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent'
|
||||||
|
|
@ -313,13 +285,12 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
|
||||||
assets = assets.filter(function(a) { return a.status === filter; });
|
assets = assets.filter(function(a) { return a.status === filter; });
|
||||||
}
|
}
|
||||||
if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });
|
if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });
|
||||||
if (selectedBinId) { var sids=collectDescendantIds(selectedBinId,BINS); assets=assets.filter(function(a){return sids.has(a.bin_id);}); }
|
if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; });
|
||||||
|
|
||||||
const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null;
|
const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null;
|
||||||
const displayTitle = activeBin
|
const displayTitle = activeBin
|
||||||
? (openProject ? openProject.name + ' · ' : '') + activeBin.name
|
? (openProject ? openProject.name + ' · ' : '') + activeBin.name
|
||||||
: (openProject ? openProject.name : 'All Assets');
|
: (openProject ? openProject.name : 'All Assets');
|
||||||
const binTree=React.useMemo(function(){return buildBinTree(BINS);},[BINS]);
|
|
||||||
const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
|
const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
|
||||||
const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
|
const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
|
||||||
|
|
||||||
|
|
@ -338,7 +309,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
|
||||||
{PROJECTS.slice(0, 8).map(function(p) {
|
{PROJECTS.slice(0, 8).map(function(p) {
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
|
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
|
||||||
onClick={function() { if (onOpenProject) onOpenProject(p); }}
|
onClick={function() { navigate('projects'); }}
|
||||||
onContextMenu={function(e) { openProjectCtx(p, e); }}>
|
onContextMenu={function(e) { openProjectCtx(p, e); }}>
|
||||||
<span className="rail-color-dot" style={{ background: p.color }} />
|
<span className="rail-color-dot" style={{ background: p.color }} />
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||||
|
|
@ -358,30 +329,45 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject, onOpenPro
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rail-list">
|
<div className="rail-list">
|
||||||
|
{creatingBin && (
|
||||||
|
<div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
autoFocus
|
||||||
|
value={newBinName}
|
||||||
|
onChange={function(e) { setNewBinName(e.target.value); }}
|
||||||
|
onKeyDown={function(e) {
|
||||||
|
if (e.key === 'Enter') submitBin(newBinName);
|
||||||
|
if (e.key === 'Escape') { setCreatingBin(false); }
|
||||||
|
}}
|
||||||
|
onBlur={function() { submitBin(newBinName); }}
|
||||||
|
placeholder="Bin name"
|
||||||
|
style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!creatingBin && BINS.length === 0 ? (
|
{!creatingBin && BINS.length === 0 ? (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
|
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
|
||||||
{openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
|
{openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : BINS.map(function(b) {
|
||||||
<BinTreeNodes nodes={binTree} depth={0}
|
const isActive = selectedBinId === b.id;
|
||||||
selectedBinId={selectedBinId} setSelectedBinId={setSelectedBinId}
|
const isDragTarget = draggingAssetId !== null && dragOverBinId === b.id;
|
||||||
draggingAssetId={draggingAssetId} dragOverBinId={dragOverBinId}
|
return (
|
||||||
onBinDragOver={onBinDragOver} onBinDrop={onBinDrop} onBinDragLeave={onBinDragLeave}
|
<div key={b.id}
|
||||||
expandedBins={expandedBins} toggleBinExpanded={toggleBinExpanded}
|
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')}
|
||||||
creatingBin={creatingBin} creatingChildOf={creatingChildOf}
|
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
|
||||||
newBinName={newBinName} setNewBinName={setNewBinName}
|
onDragOver={function(e) { onBinDragOver(b.id, e); }}
|
||||||
submitBin={submitBin} setCreatingBin={setCreatingBin} setCreatingChildOf={setCreatingChildOf}
|
onDrop={function(e) { onBinDrop(b.id, e); }}
|
||||||
createSubBin={createSubBin} openProject={openProject} />
|
onDragLeave={onBinDragLeave}
|
||||||
)}
|
style={{ cursor: 'pointer' }}
|
||||||
{creatingBin && creatingChildOf === null && (
|
title={isActive ? 'Click to clear bin filter' : 'Filter to this bin'}>
|
||||||
<div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
|
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
|
||||||
<input className="field-input" autoFocus value={newBinName}
|
<span>{b.name}</span>
|
||||||
onChange={function(e) { setNewBinName(e.target.value); }}
|
<span className="rail-count">{b.count}</span>
|
||||||
onKeyDown={function(e) { if (e.key==='Enter') submitBin(newBinName); if (e.key==='Escape') { setCreatingBin(false); } }}
|
|
||||||
onBlur={function() { submitBin(newBinName); }}
|
|
||||||
placeholder="Bin name" style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -610,8 +596,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
|
||||||
window.ZAMPP_API.fetch('/assets/' + asset.id + '/promote', { method: 'POST' })
|
window.ZAMPP_API.fetch('/assets/' + asset.id + '/promote', { method: 'POST' })
|
||||||
.then(function() {
|
.then(function() {
|
||||||
if (onChanged) onChanged();
|
if (onChanged) onChanged();
|
||||||
if (window.toast) window.toast.success('Promotion job queued. The file is being uploaded to S3 in the background.');
|
window.alert('Promotion job queued. The file is being uploaded to S3 in the background.');
|
||||||
else window.alert('Promotion job queued. The file is being uploaded to S3 in the background.');
|
|
||||||
})
|
})
|
||||||
.catch(function(e) { alert('Promotion failed: ' + e.message); });
|
.catch(function(e) { alert('Promotion failed: ' + e.message); });
|
||||||
};
|
};
|
||||||
|
|
@ -888,6 +873,5 @@ function DownloadWarningModal({ asset, onClose, onConfirm }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BinTreeNodes(p){var nodes=p.nodes,depth=p.depth,selId=p.selectedBinId,setSel=p.setSelectedBinId;var drag=p.draggingAssetId,over=p.dragOverBinId;var onOver=p.onBinDragOver,onDrop=p.onBinDrop,onLeave=p.onBinDragLeave;var expanded=p.expandedBins,toggle=p.toggleBinExpanded;var creat=p.creatingBin,parentOf=p.creatingChildOf;var newName=p.newBinName,setName=p.setNewBinName;var submit=p.submitBin,setCreat=p.setCreatingBin,setParent=p.setCreatingChildOf;var createSub=p.createSubBin,proj=p.openProject;if(!nodes||!nodes.length)return null;return nodes.map(function(b){var act=selId===b.id,idt=drag!==null&&over===b.id,hasC=b.children&&b.children.length>0,exp=expanded.has(b.id),isCk=creat&&parentOf===b.id,ind=depth*14;return React.createElement(React.Fragment,{key:b.id},React.createElement("div",{className:"rail-item"+(act?" active":"")+(idt?" droppable":""),onClick:function(){setSel(act?null:b.id);},onDragOver:function(e){onOver(b.id,e);},onDrop:function(e){onDrop(b.id,e);},onDragLeave:onLeave,style:{cursor:"pointer",paddingLeft:8+ind}},React.createElement("span",{style:{display:"inline-flex",alignItems:"center",width:16,height:16,flexShrink:0,marginRight:2,color:hasC?"var(--text-3)":"transparent",transition:"transform 120ms",transform:hasC&&exp?"rotate(90deg)":"none"},onClick:function(e){if(!hasC)return;e.stopPropagation();toggle(b.id);}},hasC&&React.createElement("svg",{width:8,height:8,viewBox:"0 0 8 8",fill:"currentColor"},React.createElement("path",{d:"M2 1l4 3-4 3V1z"}))),React.createElement(Icon,{name:binIcon(b.icon),size:13,className:"rail-icon",style:{marginRight:4}}),React.createElement("span",{style:{flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}},b.name),React.createElement("span",{className:"rail-count"},b.count),proj&&React.createElement("button",{className:"icon-btn bin-add-child-btn","aria-label":"Create sub-bin",onClick:function(e){e.stopPropagation();createSub(b.id);},style:{opacity:0,transition:"opacity 100ms",marginLeft:2,flexShrink:0},onFocus:function(e){e.currentTarget.style.opacity="1";},onBlur:function(e){e.currentTarget.style.opacity="";}},React.createElement(Icon,{name:"plus",size:10}))),isCk&&React.createElement("div",{style:{paddingLeft:8+ind+14,paddingRight:6,paddingTop:4,paddingBottom:4,display:"flex",gap:4,alignItems:"center"}},React.createElement("input",{className:"field-input",autoFocus:true,value:newName,onChange:function(e){setName(e.target.value);},onKeyDown:function(e){if(e.key==="Enter")submit(newName);if(e.key==="Escape"){setCreat(false);setParent(null);}},onBlur:function(){submit(newName);},placeholder:"Sub-bin name",style:{fontSize:12,height:26,padding:"0 6px",flex:1}})),hasC&&exp&&React.createElement(BinTreeNodes,Object.assign({},p,{nodes:b.children,depth:depth+1})));});}
|
|
||||||
window.Library = Library;
|
window.Library = Library;
|
||||||
window.AssetCard = AssetCard;
|
window.AssetCard = AssetCard;
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,6 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audio meter ───────────────────────────────────────────────────────────────
|
// ── Audio meter ───────────────────────────────────────────────────────────────
|
||||||
// Simulated VU meter — real values would require a WebAudio analyzer on the
|
|
||||||
// HLS stream. For now, animate a plausible signal when on-air. (Named PoAudioMeter
|
// HLS stream. For now, animate a plausible signal when on-air. (Named PoAudioMeter
|
||||||
// to avoid colliding with the global AudioMeter from visuals.jsx.)
|
// to avoid colliding with the global AudioMeter from visuals.jsx.)
|
||||||
function PoAudioMeter({ onAir }) {
|
function PoAudioMeter({ onAir }) {
|
||||||
|
|
@ -447,8 +446,6 @@ function ProgramMonitor({ channel, engine, elapsed }) {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const vid = videoRef.current;
|
const vid = videoRef.current;
|
||||||
if (!vid) return;
|
if (!vid) return;
|
||||||
|
|
||||||
// Tear down any previous HLS instance before re-evaluating.
|
|
||||||
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
|
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
|
||||||
if (!onAir) { vid.src = ''; return; }
|
if (!onAir) { vid.src = ''; return; }
|
||||||
|
|
||||||
|
|
@ -526,6 +523,18 @@ function ProgramMonitor({ channel, engine, elapsed }) {
|
||||||
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
|
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
|
||||||
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
|
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
return (
|
||||||
|
<div className="po-pgm">
|
||||||
|
{/* Screen */}
|
||||||
|
<div className="po-screen">
|
||||||
|
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
|
||||||
|
|
||||||
|
{/* ON AIR badge */}
|
||||||
|
{onAir && (
|
||||||
|
<div className="po-onair-badge">ON AIR</div>
|
||||||
|
)}
|
||||||
|
=======
|
||||||
// SCTE break countdown (seconds remaining in the active break).
|
// SCTE break countdown (seconds remaining in the active break).
|
||||||
const breakRemain = scte && scte.endsAt
|
const breakRemain = scte && scte.endsAt
|
||||||
? Math.max(0, (new Date(scte.endsAt).getTime() - Date.now()) / 1000)
|
? Math.max(0, (new Date(scte.endsAt).getTime() - Date.now()) / 1000)
|
||||||
|
|
@ -543,6 +552,7 @@ function ProgramMonitor({ channel, engine, elapsed }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
|
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
|
||||||
|
>>>>>>> main
|
||||||
|
|
||||||
{!onAir && (
|
{!onAir && (
|
||||||
<div className="po-screen-offline">
|
<div className="po-screen-offline">
|
||||||
|
|
@ -551,12 +561,23 @@ function ProgramMonitor({ channel, engine, elapsed }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
{/* Timecode overlay */}
|
||||||
|
{onAir && (
|
||||||
|
<div className="po-tc-overlay mono">{fmtTimecode(elapsed)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio meters */}
|
||||||
|
<div className="po-meters-wrap">
|
||||||
|
<AudioMeter onAir={onAir} />
|
||||||
|
=======
|
||||||
{onAir && (
|
{onAir && (
|
||||||
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
|
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="po-meters-wrap">
|
<div className="po-meters-wrap">
|
||||||
<PoAudioMeter onAir={onAir} />
|
<PoAudioMeter onAir={onAir} />
|
||||||
|
>>>>>>> main
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -690,12 +711,6 @@ function Scte35Panel({ channel, engine, breaks, onReload, onError }) {
|
||||||
<div className="po-card po-scte-card">
|
<div className="po-card po-scte-card">
|
||||||
<div className="po-card-head">
|
<div className="po-card-head">
|
||||||
<span className="po-section-label">SCTE-35 Break</span>
|
<span className="po-section-label">SCTE-35 Break</span>
|
||||||
{scte
|
|
||||||
? <span className="po-scte-stub-badge" style={{ color: '#f59e0b' }}>● ON AIR</span>
|
|
||||||
: pending.length > 0
|
|
||||||
? <span className="po-scte-stub-badge">{pending.length} queued</span>
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
<div className="po-scte-body">
|
<div className="po-scte-body">
|
||||||
{scte && (
|
{scte && (
|
||||||
<div className="po-scte-active mono" style={{ color: '#f59e0b', fontWeight: 600, fontSize: 12 }}>
|
<div className="po-scte-active mono" style={{ color: '#f59e0b', fontWeight: 600, fontSize: 12 }}>
|
||||||
|
|
@ -745,6 +760,10 @@ function NowPlayingCard({ engine, elapsed, items }) {
|
||||||
const clipDurSecs = currentItem ? itemEffectiveDuration(currentItem) : 0;
|
const clipDurSecs = currentItem ? itemEffectiveDuration(currentItem) : 0;
|
||||||
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
|
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
|
||||||
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
|
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> main
|
||||||
const nextItem = items[engine.currentIndex + 1] || null;
|
const nextItem = items[engine.currentIndex + 1] || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -858,6 +877,159 @@ function Timeline({ items, activeIndex, elapsed, breaks }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let playheadPct = 0;
|
||||||
|
if (activeIndex >= 0 && totalSecs > 0) {
|
||||||
|
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||||
|
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
|
||||||
|
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending position-based breaks → markers at the end of their playlist_pos clip.
|
||||||
|
const breakMarkers = [];
|
||||||
|
if (totalSecs > 0) {
|
||||||
|
for (const b of (breaks || [])) {
|
||||||
|
if (b.status !== 'pending' || b.playlist_pos == null) continue;
|
||||||
|
const pos = Math.min(b.playlist_pos, items.length - 1);
|
||||||
|
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||||
|
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="po-pgm">
|
||||||
|
<div className="po-screen">
|
||||||
|
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
|
||||||
|
|
||||||
|
{/* ON AIR / SCTE BREAK badge */}
|
||||||
|
{onAir && scte && (
|
||||||
|
<div className="po-onair-badge" style={{ background: '#f59e0b' }}>
|
||||||
|
SCTE BREAK{breakRemain > 0 ? ' ' + Math.ceil(breakRemain) + 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onAir && !scte && <div className="po-onair-badge">ON AIR</div>}
|
||||||
|
|
||||||
|
{!onAir && (
|
||||||
|
<div className="po-screen-offline">
|
||||||
|
<span className="po-screen-offline-dot" />
|
||||||
|
<span>Channel stopped</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onAir && (
|
||||||
|
<div className="po-tc-overlay mono">{playoutFmtTC(elapsed)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="po-meters-wrap">
|
||||||
|
<PoAudioMeter onAir={onAir} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="po-tl-empty muted">Add clips to the playlist to see the timeline.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Compute offset of active clip for the playhead
|
||||||
|
=======
|
||||||
|
>>>>>>> main
|
||||||
|
let playheadPct = 0;
|
||||||
|
if (activeIndex >= 0 && totalSecs > 0) {
|
||||||
|
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||||
|
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
|
||||||
|
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
|
// Pending position-based breaks → markers at the end of their playlist_pos clip.
|
||||||
|
const breakMarkers = [];
|
||||||
|
if (totalSecs > 0) {
|
||||||
|
for (const b of (breaks || [])) {
|
||||||
|
if (b.status !== 'pending' || b.playlist_pos == null) continue;
|
||||||
|
const pos = Math.min(b.playlist_pos, items.length - 1);
|
||||||
|
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||||
|
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>>>>>>> main
|
||||||
|
const COLORS = ['#3b82f6','#8b5cf6','#06b6d4','#10b981','#f59e0b','#ec4899','#6366f1','#14b8a6'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="po-tl">
|
||||||
|
<div className="po-tl-head">
|
||||||
|
<span className="po-section-label">Timeline</span>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<span className="mono muted" style={{ fontSize: 11 }}>{fmtDuration(totalSecs)} total</span>
|
||||||
|
</div>
|
||||||
|
<div className="po-tl-track-wrap">
|
||||||
|
{/* Playhead */}
|
||||||
|
{activeIndex >= 0 && (
|
||||||
|
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
|
||||||
|
)}
|
||||||
|
=======
|
||||||
|
<span className="mono muted" style={{ fontSize: 11 }}>{playoutFmtDur(totalSecs)} total</span>
|
||||||
|
</div>
|
||||||
|
<div className="po-tl-track-wrap">
|
||||||
|
{activeIndex >= 0 && (
|
||||||
|
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
|
||||||
|
)}
|
||||||
|
{breakMarkers.map(m => (
|
||||||
|
<div key={m.id} className="po-tl-scte-marker" style={{ left: m.pct + '%' }}
|
||||||
|
title={'SCTE-35 break · ' + m.dur + 's'} />
|
||||||
|
))}
|
||||||
|
>>>>>>> main
|
||||||
|
<div className="po-tl-track">
|
||||||
|
{items.map((it, i) => {
|
||||||
|
const dur = itemEffectiveDuration(it);
|
||||||
|
const pct = totalSecs > 0 ? (dur / totalSecs) * 100 : 0;
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
const color = COLORS[i % COLORS.length];
|
||||||
|
return (
|
||||||
|
<div key={it.id}
|
||||||
|
className={'po-tl-clip' + (isActive ? ' po-tl-clip--active' : '')}
|
||||||
|
style={{ width: pct + '%', '--clip-color': color }}
|
||||||
|
<<<<<<< HEAD
|
||||||
|
title={`${it.clip_name || it.asset_id} · ${fmtDuration(dur)}`}>
|
||||||
|
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
|
||||||
|
<span className="po-tl-clip-dur mono">{fmtDuration(dur)}</span>
|
||||||
|
=======
|
||||||
|
title={`${it.clip_name || it.asset_id} · ${playoutFmtDur(dur)}`}>
|
||||||
|
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
|
||||||
|
<span className="po-tl-clip-dur mono">{playoutFmtDur(dur)}</span>
|
||||||
|
>>>>>>> main
|
||||||
|
{it.media_status === 'staging' && (
|
||||||
|
<span className="po-tl-staging-dot" title="Staging…" />
|
||||||
|
)}
|
||||||
|
{it.media_status === 'error' && (
|
||||||
|
<span className="po-tl-error-dot" title="Stage error" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
{/* Time ruler (rough marks) */}
|
||||||
|
<div className="po-tl-ruler">
|
||||||
|
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<span key={i} className="po-tl-ruler-mark mono"
|
||||||
|
style={{ left: (i * 25) + '%' }}>
|
||||||
|
{fmtDuration((totalSecs * i) / 4)}
|
||||||
|
=======
|
||||||
|
<div className="po-tl-ruler">
|
||||||
|
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<span key={i} className="po-tl-ruler-mark mono" style={{ left: (i * 25) + '%' }}>
|
||||||
|
{playoutFmtDur((totalSecs * i) / 4)}
|
||||||
|
>>>>>>> main
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── As-run drawer ─────────────────────────────────────────────────────────────
|
// ── As-run drawer ─────────────────────────────────────────────────────────────
|
||||||
function AsRunDrawer({ channel, refreshKey, open, onClose }) {
|
function AsRunDrawer({ channel, refreshKey, open, onClose }) {
|
||||||
const [rows, setRows] = React.useState([]);
|
const [rows, setRows] = React.useState([]);
|
||||||
|
|
@ -1007,6 +1179,15 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
|
|
||||||
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
|
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
|
||||||
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
|
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
|
||||||
|
<<<<<<< HEAD
|
||||||
|
const onAir = ch.status === 'running';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="po-root">
|
||||||
|
{/* ── Top rail: monitor + right panel ── */}
|
||||||
|
<div className="po-top">
|
||||||
|
{/* PGM monitor + transport */}
|
||||||
|
=======
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="po-root">
|
<div className="po-root">
|
||||||
|
|
@ -1014,6 +1195,7 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
|
|
||||||
{/* ── Top rail: monitor + right panel ── */}
|
{/* ── Top rail: monitor + right panel ── */}
|
||||||
<div className="po-top">
|
<div className="po-top">
|
||||||
|
>>>>>>> main
|
||||||
<div className="po-pgm-col">
|
<div className="po-pgm-col">
|
||||||
<ProgramMonitor channel={ch} engine={engine} elapsed={elapsed} />
|
<ProgramMonitor channel={ch} engine={engine} elapsed={elapsed} />
|
||||||
<Transport
|
<Transport
|
||||||
|
|
@ -1021,10 +1203,17 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
playlistId={playlistId}
|
playlistId={playlistId}
|
||||||
items={items}
|
items={items}
|
||||||
onStatus={loadItems}
|
onStatus={loadItems}
|
||||||
|
<<<<<<< HEAD
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right rail */}
|
||||||
|
=======
|
||||||
onError={setActionErr}
|
onError={setActionErr}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
>>>>>>> main
|
||||||
<div className="po-rail">
|
<div className="po-rail">
|
||||||
{/* Channel controls */}
|
{/* Channel controls */}
|
||||||
<div className="po-card po-channel-card">
|
<div className="po-card po-channel-card">
|
||||||
|
|
@ -1048,6 +1237,20 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{ch.error_message && <div className="alert error" style={{ marginTop: 8 }}>{ch.error_message}</div>}
|
{ch.error_message && <div className="alert error" style={{ marginTop: 8 }}>{ch.error_message}</div>}
|
||||||
|
<<<<<<< HEAD
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Now playing */}
|
||||||
|
<NowPlayingCard engine={engine} elapsed={elapsed} items={items} />
|
||||||
|
|
||||||
|
{/* SCTE-35 */}
|
||||||
|
<Scte35Panel channel={ch} />
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="po-rail-actions">
|
||||||
|
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
|
||||||
|
{binOpen ? '▸ Hide' : '▾ Media Bin'}
|
||||||
|
=======
|
||||||
{actionErr && (
|
{actionErr && (
|
||||||
<div className="alert error" style={{ marginTop: 8 }} onClick={() => setActionErr(null)}>
|
<div className="alert error" style={{ marginTop: 8 }} onClick={() => setActionErr(null)}>
|
||||||
{actionErr}
|
{actionErr}
|
||||||
|
|
@ -1063,6 +1266,7 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
<div className="po-rail-actions">
|
<div className="po-rail-actions">
|
||||||
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
|
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
|
||||||
{binOpen ? '▸ Hide bin' : '▾ Media Bin'}
|
{binOpen ? '▸ Hide bin' : '▾ Media Bin'}
|
||||||
|
>>>>>>> main
|
||||||
</button>
|
</button>
|
||||||
<button className="btn ghost sm po-rail-action-btn" onClick={() => setAsRunOpen(true)}>
|
<button className="btn ghost sm po-rail-action-btn" onClick={() => setAsRunOpen(true)}>
|
||||||
As-Run Log
|
As-Run Log
|
||||||
|
|
@ -1071,8 +1275,16 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
{/* Media bin (collapsible, below top rail) */}
|
||||||
|
{binOpen && (
|
||||||
|
<MediaBin projectId={ch.project_id} />
|
||||||
|
)}
|
||||||
|
=======
|
||||||
{binOpen && <MediaBin projectId={ch.project_id} />}
|
{binOpen && <MediaBin projectId={ch.project_id} />}
|
||||||
|
>>>>>>> main
|
||||||
|
|
||||||
|
{/* Playlist */}
|
||||||
{playlistId && (
|
{playlistId && (
|
||||||
<Playlist
|
<Playlist
|
||||||
channel={ch}
|
channel={ch}
|
||||||
|
|
@ -1083,8 +1295,15 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
{/* Timeline */}
|
||||||
|
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} />
|
||||||
|
|
||||||
|
{/* As-run drawer */}
|
||||||
|
=======
|
||||||
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} breaks={breaks} />
|
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} breaks={breaks} />
|
||||||
|
|
||||||
|
>>>>>>> main
|
||||||
<AsRunDrawer
|
<AsRunDrawer
|
||||||
channel={ch}
|
channel={ch}
|
||||||
refreshKey={engine && engine.currentItemId}
|
refreshKey={engine && engine.currentItemId}
|
||||||
|
|
@ -1147,6 +1366,8 @@ function Playout() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-body po-page">
|
<div className="page-body po-page">
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fef3c7',
|
background: '#fef3c7',
|
||||||
borderLeft: '4px solid #f59e0b',
|
borderLeft: '4px solid #f59e0b',
|
||||||
|
|
@ -1162,6 +1383,7 @@ function Playout() {
|
||||||
}}>
|
}}>
|
||||||
⚠ Playout is in testing — not for production use.
|
⚠ Playout is in testing — not for production use.
|
||||||
</div>
|
</div>
|
||||||
|
>>>>>>> main
|
||||||
{err && <div className="alert error">{err}</div>}
|
{err && <div className="alert error">{err}</div>}
|
||||||
|
|
||||||
{channels === null && <div className="muted">Loading channels…</div>}
|
{channels === null && <div className="muted">Loading channels…</div>}
|
||||||
|
|
|
||||||
|
|
@ -292,38 +292,37 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
/* Logo wrapper — large hero with orange pulse halo. */
|
/* Logo wrapper holds the animated pulse halo behind the image. */
|
||||||
.launcher-logo-wrap {
|
.launcher-logo-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 120px;
|
width: 52px;
|
||||||
height: 120px;
|
height: 52px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.launcher-logo-pulse {
|
.launcher-logo-pulse {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 180px;
|
width: 80px;
|
||||||
height: 180px;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgba(232, 130, 28, 0.35) 0%, rgba(232, 130, 28, 0.08) 55%, transparent 70%);
|
background: radial-gradient(circle, var(--accent-soft) 0%, transparent 70%);
|
||||||
animation: logoPulse 2.8s ease-in-out infinite;
|
animation: logoPulse 3s ease-in-out infinite;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@keyframes logoPulse {
|
@keyframes logoPulse {
|
||||||
0%, 100% { transform: scale(1); opacity: 0.7; }
|
0%, 100% { transform: scale(1); opacity: 0.6; }
|
||||||
50% { transform: scale(1.18); opacity: 1; }
|
50% { transform: scale(1.15); opacity: 1; }
|
||||||
}
|
}
|
||||||
.launcher-logo {
|
.launcher-logo {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 110px;
|
width: 52px;
|
||||||
height: 110px;
|
height: 52px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter:
|
filter:
|
||||||
brightness(0) invert(1)
|
brightness(0) invert(1)
|
||||||
drop-shadow(0 0 14px rgba(232, 130, 28, 0.6))
|
drop-shadow(0 0 8px rgba(232, 130, 28, 0.35));
|
||||||
drop-shadow(0 0 4px rgba(255, 180, 60, 0.4));
|
|
||||||
animation: launcherLogoIn 400ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
animation: launcherLogoIn 400ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||||
}
|
}
|
||||||
@keyframes launcherLogoIn {
|
@keyframes launcherLogoIn {
|
||||||
|
|
@ -331,7 +330,7 @@
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.launcher-logo-pulse { animation: none; opacity: 0.6; }
|
.launcher-logo-pulse { animation: none; opacity: 0.5; }
|
||||||
.launcher-logo { animation: none; }
|
.launcher-logo { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
}
|
}
|
||||||
.source-type-grid {
|
.source-type-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.source-type-card {
|
.source-type-card {
|
||||||
|
|
|
||||||
|
|
@ -539,7 +539,6 @@
|
||||||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── SCTE-35 additions (timeline marker + active-break line) ──────────────── */
|
/* ── SCTE-35 additions (timeline marker + active-break line) ──────────────── */
|
||||||
.po-tl-scte-marker {
|
.po-tl-scte-marker {
|
||||||
position: absolute; top: 10px; bottom: 28px;
|
position: absolute; top: 10px; bottom: 28px;
|
||||||
|
|
|
||||||
|
|
@ -1066,9 +1066,6 @@
|
||||||
.rail-item .rail-icon { color: var(--text-3); }
|
.rail-item .rail-icon { color: var(--text-3); }
|
||||||
.rail-item .rail-count { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); }
|
.rail-item .rail-count { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); }
|
||||||
.rail-color-dot { width: 8px; height: 8px; border-radius: 50%; }
|
.rail-color-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
/* Show sub-bin create button only on hover of the parent rail-item */
|
|
||||||
.rail-item:hover .bin-add-child-btn { opacity: 1 !important; }
|
|
||||||
.bin-add-child-btn { padding: 0 2px; height: 18px; min-width: 18px; }
|
|
||||||
|
|
||||||
.library-main {
|
.library-main {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ function AssetThumb({ asset, size = 'md' }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VOD HLS assets: if we have an HLS rendition, we could potentially show a
|
||||||
|
// muted hover-preview here too. For now, just static thumb.
|
||||||
|
|
||||||
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
|
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
|
||||||
return (
|
return (
|
||||||
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
|
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
|
||||||
|
|
@ -112,7 +115,12 @@ function LiveThumb({ assetId, aspect }) {
|
||||||
|
|
||||||
const startHls = () => {
|
const startHls = () => {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true, maxBufferLength: 10 });
|
hls = new window.Hls({
|
||||||
|
liveSyncDurationCount: 2,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
maxBufferLength: 10,
|
||||||
|
xhrSetup: (xhr) => { xhr.withCredentials = true; }
|
||||||
|
});
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(v);
|
hls.attachMedia(v);
|
||||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue