2026-05-30 09:17:49 -04:00
|
|
|
|
import { AmcpClient } from './amcp.js';
|
|
|
|
|
|
|
|
|
|
|
|
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
|
|
|
|
|
|
//
|
|
|
|
|
|
// One sidecar container == one CasparCG Server == one logical channel (channel
|
|
|
|
|
|
// index 1 in CasparCG terms). We add the output consumer (DeckLink / NDI / SRT
|
|
|
|
|
|
// / RTMP) at start, then walk a playlist by cueing the next clip on a background
|
|
|
|
|
|
// layer (LOADBG ... AUTO) so CasparCG performs a gapless transition at end of
|
|
|
|
|
|
// the current clip.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Media is referenced by a path relative to CasparCG's configured media folder
|
|
|
|
|
|
// (/media inside the container). The mam-api stages assets from S3 to that
|
|
|
|
|
|
// shared volume and passes the resolved relative path on each item.
|
|
|
|
|
|
|
|
|
|
|
|
const CHANNEL = 1; // single CasparCG channel per sidecar
|
|
|
|
|
|
const FG_LAYER = 10; // foreground (on-air) layer
|
|
|
|
|
|
const MEDIA_ROOT = process.env.CASPAR_MEDIA_ROOT || '/media';
|
|
|
|
|
|
|
|
|
|
|
|
// Channel-id-derived HLS preview path. The mam-api proxies /live/<channel_id>/
|
|
|
|
|
|
// to this directory (shared media volume) so the UI's existing HLS player
|
|
|
|
|
|
// (capture's /live/<id> plumbing) works for playout monitors with zero new
|
|
|
|
|
|
// transport.
|
|
|
|
|
|
const CHANNEL_ID = process.env.CHANNEL_ID || '';
|
|
|
|
|
|
const HLS_DIR = CHANNEL_ID ? `${MEDIA_ROOT}/live/${CHANNEL_ID}` : '';
|
|
|
|
|
|
|
|
|
|
|
|
// CasparCG SEEK / LENGTH are in frames, not seconds. Capture standard is 59.94;
|
|
|
|
|
|
// SD/film modes need their own values. Default 60000/1001 matches both
|
|
|
|
|
|
// '1080p5994' and '1080i5994'.
|
|
|
|
|
|
function fpsFor(videoFormat) {
|
|
|
|
|
|
const f = String(videoFormat || '').toLowerCase();
|
|
|
|
|
|
if (f.endsWith('5994')) return 60000 / 1001;
|
|
|
|
|
|
if (f.endsWith('p60') || f.endsWith('i60')) return 60;
|
|
|
|
|
|
if (f.endsWith('p50') || f.endsWith('i50')) return 50;
|
|
|
|
|
|
if (f.endsWith('2997')) return 30000 / 1001;
|
|
|
|
|
|
if (f.endsWith('p30')) return 30;
|
|
|
|
|
|
if (f.endsWith('p25')) return 25;
|
|
|
|
|
|
if (f.endsWith('p24') || f.endsWith('2398')) return 24000 / 1001;
|
|
|
|
|
|
return 60000 / 1001; // safe default for the house standard
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CasparCG transition syntax fragments keyed by our item.transition value.
|
|
|
|
|
|
function transitionArgs(transition, ms, fps) {
|
|
|
|
|
|
if (!transition || transition === 'cut' || !ms) return '';
|
|
|
|
|
|
const frames = Math.max(1, Math.round((ms / 1000) * fps));
|
|
|
|
|
|
if (transition === 'mix') return ` MIX ${frames}`;
|
|
|
|
|
|
if (transition === 'wipe') return ` WIPE ${frames}`;
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Turn an absolute /media path (or a relative one) into the token CasparCG
|
|
|
|
|
|
// expects: a path relative to MEDIA_ROOT, without extension, forward-slashed.
|
|
|
|
|
|
// CasparCG resolves "subdir/clip" against its media folder + probes extensions.
|
|
|
|
|
|
function toCasparToken(mediaPath) {
|
|
|
|
|
|
let p = String(mediaPath || '');
|
|
|
|
|
|
if (p.startsWith(MEDIA_ROOT)) p = p.slice(MEDIA_ROOT.length);
|
|
|
|
|
|
p = p.replace(/^\/+/, '');
|
|
|
|
|
|
p = p.replace(/\.[^/.]+$/, ''); // strip extension
|
|
|
|
|
|
return p;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export class PlayoutManager {
|
|
|
|
|
|
constructor() {
|
|
|
|
|
|
this.amcp = new AmcpClient({
|
|
|
|
|
|
host: process.env.CASPAR_HOST || '127.0.0.1',
|
|
|
|
|
|
port: parseInt(process.env.CASPAR_PORT || '5250', 10),
|
|
|
|
|
|
});
|
|
|
|
|
|
this.state = {
|
|
|
|
|
|
running: false,
|
|
|
|
|
|
outputType: null,
|
|
|
|
|
|
outputConfig: null,
|
|
|
|
|
|
videoFormat: null,
|
|
|
|
|
|
playlist: [], // resolved items in play order
|
|
|
|
|
|
currentIndex: -1,
|
|
|
|
|
|
loop: false,
|
|
|
|
|
|
currentClip: null,
|
|
|
|
|
|
startedAt: null,
|
|
|
|
|
|
lastError: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
this._advanceTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async _consumerCommand(outputType, cfg) {
|
|
|
|
|
|
// Returns the AMCP ADD argument string for the requested output target.
|
|
|
|
|
|
if (outputType === 'decklink') {
|
|
|
|
|
|
const dev = cfg.device_index || 1;
|
|
|
|
|
|
return `DECKLINK DEVICE ${dev} EMBEDDED_AUDIO`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (outputType === 'ndi') {
|
|
|
|
|
|
const name = cfg.ndi_name || 'DRAGONFLIGHT';
|
|
|
|
|
|
return `NDI NAME "${name}"`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (outputType === 'srt' || outputType === 'rtmp') {
|
2026-05-30 10:51:35 -04:00
|
|
|
|
// CasparCG 2.3 streams via the FFMPEG consumer, invoked with the STREAM
|
|
|
|
|
|
// keyword (FILE/STREAM are interchangeable aliases for it; the bare word
|
|
|
|
|
|
// "FFMPEG" is the PRODUCER and is NOT a valid consumer keyword). Args must
|
|
|
|
|
|
// use ffmpeg's -param:stream form (-codec:v, not -vcodec) or CasparCG
|
|
|
|
|
|
// rejects them. The channel feeds the consumer as RGBA, so a
|
|
|
|
|
|
// format=yuv420p filter is required before libx264.
|
2026-05-30 09:17:49 -04:00
|
|
|
|
const url = cfg.url || '';
|
|
|
|
|
|
if (outputType === 'srt') {
|
|
|
|
|
|
const latency = cfg.latency || 200;
|
|
|
|
|
|
const full = url.includes('latency=') ? url : `${url}${url.includes('?') ? '&' : '?'}latency=${latency}`;
|
2026-05-30 10:51:35 -04:00
|
|
|
|
return `STREAM "${full}" -format mpegts -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
|
2026-05-30 09:17:49 -04:00
|
|
|
|
}
|
|
|
|
|
|
const target = cfg.key ? `${url}/${cfg.key}` : url;
|
2026-05-30 10:51:35 -04:00
|
|
|
|
return `STREAM "${target}" -format flv -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
|
2026-05-30 09:17:49 -04:00
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`Unknown output_type: ${outputType}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Start the channel: bring up CasparCG's primary output consumer for the
|
|
|
|
|
|
// target, plus a second FFMPEG consumer writing HLS for the UI preview
|
|
|
|
|
|
// monitor (~4-6s lag, reuses capture's /live/<id> plumbing).
|
2026-05-31 13:01:21 -04:00
|
|
|
|
//
|
|
|
|
|
|
// The primary consumer failure is NON-FATAL. CasparCG can decode and route
|
|
|
|
|
|
// media through its pipeline even without an output consumer. This means:
|
|
|
|
|
|
// - NDI channels work (load/play/transport) even if libndi.so is absent.
|
|
|
|
|
|
// - SRT/RTMP channels work even if the destination URL is unreachable.
|
|
|
|
|
|
// - The HLS preview consumer is always attempted independently.
|
|
|
|
|
|
//
|
|
|
|
|
|
// state.consumerError is set when the primary consumer fails so the mam-api
|
|
|
|
|
|
// can surface a warning in the channel status without blocking operation.
|
2026-05-30 09:17:49 -04:00
|
|
|
|
async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) {
|
|
|
|
|
|
await this.amcp.waitReady(30000);
|
|
|
|
|
|
|
2026-05-31 13:01:21 -04:00
|
|
|
|
// Set the channel video mode first.
|
2026-05-30 09:17:49 -04:00
|
|
|
|
try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); }
|
|
|
|
|
|
catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); }
|
|
|
|
|
|
|
2026-05-31 13:01:21 -04:00
|
|
|
|
// Primary output consumer — non-fatal.
|
|
|
|
|
|
let consumerError = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const consumer = await this._consumerCommand(outputType, outputConfig);
|
|
|
|
|
|
await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
consumerError = err.message;
|
|
|
|
|
|
console.warn(`[playout] primary consumer ADD failed (continuing without output): ${err.message}`);
|
|
|
|
|
|
}
|
2026-05-30 09:17:49 -04:00
|
|
|
|
|
2026-05-31 13:01:21 -04:00
|
|
|
|
// HLS preview consumer — always attempt, independently non-fatal.
|
2026-05-30 09:17:49 -04:00
|
|
|
|
if (HLS_DIR) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this._addHlsConsumer();
|
|
|
|
|
|
console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn(`[playout] HLS preview consumer failed: ${err.message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.state.running = true;
|
|
|
|
|
|
this.state.outputType = outputType;
|
|
|
|
|
|
this.state.outputConfig = outputConfig;
|
|
|
|
|
|
this.state.videoFormat = videoFormat;
|
|
|
|
|
|
this.state.fps = fpsFor(videoFormat);
|
|
|
|
|
|
this.state.startedAt = new Date().toISOString();
|
2026-05-31 13:01:21 -04:00
|
|
|
|
this.state.lastError = consumerError;
|
|
|
|
|
|
console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}${consumerError ? ' ⚠ consumer: ' + consumerError : ''}`);
|
2026-05-30 09:17:49 -04:00
|
|
|
|
return this.getStatus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Low-bitrate HLS for the web UI preview. Segments land in the shared media
|
2026-05-31 13:35:45 -04:00
|
|
|
|
// volume; the mam-api serves /media/live/<channel_id>/* from there.
|
2026-05-30 09:17:49 -04:00
|
|
|
|
async _addHlsConsumer() {
|
2026-05-31 13:35:45 -04:00
|
|
|
|
// The CasparCG channel feeds this consumer in real time, and its frame
|
|
|
|
|
|
// timestamps are irregular ("packet with pts X has duration 0" warnings).
|
|
|
|
|
|
// With frame-count GOPs (-g 60) the HLS muxer split points drift, producing
|
|
|
|
|
|
// erratic segment durations (0.4s–4.2s) and TARGETDURATION violations. The
|
|
|
|
|
|
// result is a live playlist hls.js parses but can never sync to — it
|
|
|
|
|
|
// reloads forever ("sliding 0.00 / prev-sn na / MISSED") and never appends
|
|
|
|
|
|
// a fragment, so the preview stays black.
|
|
|
|
|
|
//
|
2026-05-31 13:44:45 -04:00
|
|
|
|
// The HLS preview is a VIDEO-ONLY confidence monitor. We deliberately drop
|
|
|
|
|
|
// audio (-an).
|
2026-05-31 13:38:21 -04:00
|
|
|
|
//
|
2026-05-31 13:44:45 -04:00
|
|
|
|
// Why: CasparCG's real-time channel feeds the FFMPEG consumer an audio
|
|
|
|
|
|
// stream whose muxed time_base comes out as 1/0 (infinity). ffmpeg itself
|
|
|
|
|
|
// can't decode the resulting playlist ("Invalid data ... abuffer: Value inf
|
|
|
|
|
|
// for parameter 'time_base'"), and hls.js silently fails to append the
|
|
|
|
|
|
// fragment after demux — the video element sits at readyState 0 and the
|
|
|
|
|
|
// preview stays black. Dropping audio removes the broken stream entirely;
|
|
|
|
|
|
// the remaining video track is clean h264 and plays in hls.js. A confidence
|
|
|
|
|
|
// monitor doesn't need audio — the real program audio rides the primary
|
|
|
|
|
|
// SRT/RTMP/SDI/NDI output, which is unaffected.
|
|
|
|
|
|
//
|
|
|
|
|
|
// NOTE: encoder options like -g / -r / -force_key_frames are NOT honored
|
|
|
|
|
|
// here — CasparCG's FFMPEG consumer applies args to the muxer, not the
|
|
|
|
|
|
// encoder (it logs "Unused option"). Segment cadence follows the channel's
|
|
|
|
|
|
// own keyframes; that's fine for a video-only preview.
|
2026-05-30 09:17:49 -04:00
|
|
|
|
const out = `${HLS_DIR}/index.m3u8`;
|
|
|
|
|
|
const args = [
|
2026-05-30 10:51:35 -04:00
|
|
|
|
`FILE "${out}"`,
|
2026-05-30 09:17:49 -04:00
|
|
|
|
'-format hls',
|
|
|
|
|
|
'-hls_time 2',
|
2026-05-31 13:35:45 -04:00
|
|
|
|
'-hls_list_size 8',
|
|
|
|
|
|
'-hls_flags delete_segments+append_list+independent_segments',
|
2026-05-30 10:51:35 -04:00
|
|
|
|
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 800k -maxrate 1M -bufsize 2M',
|
2026-05-31 13:44:45 -04:00
|
|
|
|
'-an',
|
2026-05-30 10:51:35 -04:00
|
|
|
|
'-filter:v format=yuv420p',
|
2026-05-30 09:17:49 -04:00
|
|
|
|
].join(' ');
|
|
|
|
|
|
await this.amcp.send(`ADD ${CHANNEL} ${args}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async stopChannel() {
|
|
|
|
|
|
this._clearAdvance();
|
|
|
|
|
|
try { await this.amcp.send(`STOP ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
|
|
|
|
|
|
try { await this.amcp.send(`CLEAR ${CHANNEL}`); } catch (_) {}
|
|
|
|
|
|
this.state.running = false;
|
|
|
|
|
|
this.state.playlist = [];
|
|
|
|
|
|
this.state.currentIndex = -1;
|
|
|
|
|
|
this.state.currentClip = null;
|
|
|
|
|
|
console.log('[playout] channel stopped');
|
|
|
|
|
|
return { stopped: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load a playlist (array of { id, asset_id, media_path, in_point, out_point,
|
|
|
|
|
|
// transition, transition_ms, clip_name }) and start playing from index 0.
|
|
|
|
|
|
async loadPlaylist({ items = [], loop = false }) {
|
2026-05-31 13:01:21 -04:00
|
|
|
|
if (!this.state.running) {
|
|
|
|
|
|
throw new Error('Channel not started — call /channel/start first');
|
|
|
|
|
|
}
|
2026-05-30 09:17:49 -04:00
|
|
|
|
this.state.playlist = items;
|
|
|
|
|
|
this.state.loop = !!loop;
|
|
|
|
|
|
this.state.currentIndex = -1;
|
|
|
|
|
|
if (items.length === 0) return this.getStatus();
|
|
|
|
|
|
await this._playIndex(0);
|
|
|
|
|
|
return this.getStatus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async _playIndex(index) {
|
|
|
|
|
|
const item = this.state.playlist[index];
|
|
|
|
|
|
if (!item) return;
|
|
|
|
|
|
const fps = this.state.fps || fpsFor(this.state.videoFormat);
|
|
|
|
|
|
const token = toCasparToken(item.media_path);
|
|
|
|
|
|
const seek = item.in_point ? ` SEEK ${Math.round(item.in_point * fps)}` : '';
|
|
|
|
|
|
const length = (item.out_point && item.out_point > (item.in_point || 0))
|
|
|
|
|
|
? ` LENGTH ${Math.round((item.out_point - (item.in_point || 0)) * fps)}`
|
|
|
|
|
|
: '';
|
|
|
|
|
|
const trans = transitionArgs(item.transition, item.transition_ms, fps);
|
|
|
|
|
|
|
|
|
|
|
|
// PLAY puts the clip on the foreground layer immediately (first clip), with
|
|
|
|
|
|
// the configured transition. Subsequent clips are cued via LOADBG ... AUTO
|
|
|
|
|
|
// for a gapless hand-off; see _scheduleAdvance.
|
|
|
|
|
|
await this.amcp.send(`PLAY ${CHANNEL}-${FG_LAYER} "${token}"${seek}${length}${trans}`);
|
|
|
|
|
|
this.state.currentIndex = index;
|
|
|
|
|
|
this.state.currentClip = item.clip_name || token;
|
|
|
|
|
|
console.log(`[playout] PLAY [${index}] ${token}`);
|
|
|
|
|
|
this._reportAsRunStart(item);
|
|
|
|
|
|
this._scheduleAdvance(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 10:51:35 -04:00
|
|
|
|
// Effective on-air duration of an item in milliseconds. Prefers an explicit
|
|
|
|
|
|
// in/out trim, else the asset's full duration. Returns null when unknown (no
|
|
|
|
|
|
// duration metadata + no out_point) so the caller can skip the timer.
|
|
|
|
|
|
_itemDurationMs(item) {
|
|
|
|
|
|
const inS = item.in_point || 0;
|
|
|
|
|
|
if (item.out_point && item.out_point > inS) return (item.out_point - inS) * 1000;
|
|
|
|
|
|
if (item.asset_duration_ms != null) return Math.max(0, item.asset_duration_ms - inS * 1000);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CasparCG's LOADBG ... AUTO swaps the cued background clip to foreground when
|
|
|
|
|
|
// the current clip ends, giving a gapless visual take. But CasparCG won't cue
|
|
|
|
|
|
// clip N+2 on its own and won't move OUR pointer / as-run bookkeeping. So we
|
|
|
|
|
|
// also arm a duration-based timer: when the current clip is due to end we
|
|
|
|
|
|
// advance currentIndex and cue the following clip. This keeps an arbitrary-
|
|
|
|
|
|
// length playlist walking, not just the first two items.
|
2026-05-30 09:17:49 -04:00
|
|
|
|
_scheduleAdvance(item) {
|
|
|
|
|
|
this._clearAdvance();
|
|
|
|
|
|
const next = this._nextIndex();
|
2026-05-30 10:51:35 -04:00
|
|
|
|
if (next === null) return; // end of a non-looping playlist
|
2026-05-30 09:17:49 -04:00
|
|
|
|
const nextItem = this.state.playlist[next];
|
|
|
|
|
|
const nextToken = toCasparToken(nextItem.media_path);
|
|
|
|
|
|
const fps = this.state.fps || fpsFor(this.state.videoFormat);
|
|
|
|
|
|
const trans = transitionArgs(nextItem.transition, nextItem.transition_ms, fps);
|
|
|
|
|
|
// Cue next on background with AUTO so CasparCG performs the gapless take.
|
|
|
|
|
|
this.amcp.send(`LOADBG ${CHANNEL}-${FG_LAYER} "${nextToken}" AUTO${trans}`)
|
|
|
|
|
|
.catch((err) => console.warn(`[playout] LOADBG failed: ${err.message}`));
|
2026-05-30 10:51:35 -04:00
|
|
|
|
|
|
|
|
|
|
// Arm the pointer-advance timer. Without duration metadata we can't time the
|
|
|
|
|
|
// hand-off; leave AUTO to take clip N+1 visually but log a warning since the
|
|
|
|
|
|
// pointer (and thus clip N+2 cueing) will stall.
|
|
|
|
|
|
const durMs = this._itemDurationMs(item);
|
|
|
|
|
|
if (durMs == null) {
|
|
|
|
|
|
console.warn(`[playout] no duration for clip [${this.state.currentIndex}] — pointer advance stalled after this clip`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._advanceTimer = setTimeout(() => {
|
|
|
|
|
|
this._advanceTimer = null;
|
|
|
|
|
|
// The AUTO take already happened in CasparCG; just move our pointer and
|
|
|
|
|
|
// cue the clip after next. _playIndex would re-PLAY and double-take, so we
|
|
|
|
|
|
// advance state directly and re-arm.
|
|
|
|
|
|
this.state.currentIndex = next;
|
|
|
|
|
|
this.state.currentClip = nextItem.clip_name || nextToken;
|
|
|
|
|
|
console.log(`[playout] advance -> [${next}] ${nextToken}`);
|
|
|
|
|
|
this._reportAsRunStart(nextItem);
|
|
|
|
|
|
this._scheduleAdvance(nextItem);
|
|
|
|
|
|
}, Math.max(250, durMs));
|
2026-05-30 09:17:49 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_nextIndex() {
|
|
|
|
|
|
const n = this.state.currentIndex + 1;
|
|
|
|
|
|
if (n < this.state.playlist.length) return n;
|
|
|
|
|
|
if (this.state.loop && this.state.playlist.length > 0) return 0;
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_clearAdvance() {
|
|
|
|
|
|
if (this._advanceTimer) { clearTimeout(this._advanceTimer); this._advanceTimer = null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async skip() {
|
|
|
|
|
|
const next = this._nextIndex();
|
|
|
|
|
|
if (next === null) { await this.stopChannel(); return this.getStatus(); }
|
|
|
|
|
|
await this._playIndex(next);
|
|
|
|
|
|
return this.getStatus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async pause() {
|
|
|
|
|
|
try { await this.amcp.send(`PAUSE ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
|
|
|
|
|
|
return this.getStatus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async resume() {
|
|
|
|
|
|
try { await this.amcp.send(`RESUME ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
|
|
|
|
|
|
return this.getStatus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_reportAsRunStart(item) {
|
|
|
|
|
|
// The mam-api owns the as-run table; the sidecar just logs locally. The API
|
|
|
|
|
|
// polls /status and writes as-run rows on clip change. Keeping the DB write
|
|
|
|
|
|
// in the API avoids giving the sidecar a DB connection.
|
|
|
|
|
|
this.state.currentItemId = item.id || null;
|
|
|
|
|
|
this.state.currentItemStartedAt = new Date().toISOString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getStatus() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
running: this.state.running,
|
|
|
|
|
|
outputType: this.state.outputType,
|
|
|
|
|
|
videoFormat: this.state.videoFormat,
|
|
|
|
|
|
currentIndex: this.state.currentIndex,
|
|
|
|
|
|
currentClip: this.state.currentClip,
|
|
|
|
|
|
currentItemId: this.state.currentItemId || null,
|
|
|
|
|
|
currentItemStartedAt: this.state.currentItemStartedAt || null,
|
|
|
|
|
|
playlistLength: this.state.playlist.length,
|
|
|
|
|
|
loop: this.state.loop,
|
|
|
|
|
|
startedAt: this.state.startedAt,
|
|
|
|
|
|
lastError: this.state.lastError,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default new PlayoutManager();
|