Compare commits

..

7 commits

Author SHA1 Message Date
01a19c0d69 feat(hls): add xhrSetup withCredentials to hls.js instances in visuals.jsx 2026-06-03 04:22:08 +00:00
39e010544c fix(merge): resolve conflicts in playout UI and styles 2026-06-03 04:21:38 +00:00
d58982ad18 feat(hls): return type='hls' in stream endpoint and add audio sync placeholders in editor 2026-06-03 04:18:29 +00:00
a1b8211ea1 feat(editor): add Hls.js support for source and program monitors 2026-06-03 04:17:17 +00:00
ac5a667e65 feat(playout): redesigned MCR CSS — monitor, timeline, SCTE-35, drawer 2026-05-31 19:30:59 -04:00
1ca295d799 fix: upload growing file to S3 on stop so proxy job succeeds
When growing_enabled=true the capture container writes the master to
/growing/{projectId}/{clipName}.{ext} instead of streaming it to S3.
The capture container's graceful-shutdown handler (running during the
Docker stop) calls POST /assets/:id/finalize with the expected S3 key,
which queues a proxy job.  That key never had data in S3 so the proxy
worker downloaded an empty object and failed with 'unable to open file'.

Fix: in the stop endpoint, after the container has exited (meaning
ffmpeg has finished flushing the growing file), upload the growing file
to S3 from the mam-api node (which has /growing mounted).  The upload
completes before the HTTP response is sent, so by the time the client
refreshes and the BullMQ worker dequeues the proxy job the S3 object
exists.

Also handles the edge case where finalize already ran and flipped the
asset to 'processing' — we still do the upload so the already-queued
proxy job can succeed.  Best-effort: a missing growing file (empty
recording or SMB-path scenario) is logged but does not fail the stop."
2026-05-31 19:30:13 -04:00
be819353a7 feat(playout): redesigned MCR screen — design polish + real API wiring 2026-05-31 19:28:08 -04:00
22 changed files with 613 additions and 451 deletions

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -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(

View file

@ -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); }
}); });

View file

@ -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);

View file

@ -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+/);

View file

@ -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) {

View file

@ -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) {

View file

@ -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 ────────────────────────────────────────────────────

View file

@ -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

View file

@ -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;

View file

@ -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; });

View file

@ -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(() => {});

View file

@ -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;

View file

@ -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>}

View file

@ -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; }
} }

View file

@ -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 {

View file

@ -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;

View file

@ -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;

View file

@ -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, () => {