timeline-state-resolver
Version:
Have timeline, control stuff
819 lines • 38.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CasparCGDevice = void 0;
const _ = require("underscore");
const device_1 = require("../../devices/device");
const casparcg_connection_1 = require("casparcg-connection");
const timeline_state_resolver_types_1 = require("timeline-state-resolver-types");
const casparcg_state_1 = require("casparcg-state");
const doOnTime_1 = require("../../devices/doOnTime");
const got_1 = require("got");
const transitionHandler_1 = require("../../devices/transitions/transitionHandler");
const debug_1 = require("debug");
const lib_1 = require("../../lib");
const debug = (0, debug_1.default)('timeline-state-resolver:casparcg');
const MEDIA_RETRY_INTERVAL = 10 * 1000; // default time in ms between checking whether a file needs to be retried loading
/**
* This class is used to interface with CasparCG installations. It creates
* device states from timeline states and then diffs these states to generate
* commands. It depends on the DoOnTime class to execute the commands timely or,
* optionally, uses the CasparCG command scheduling features.
*/
class CasparCGDevice extends device_1.DeviceWithState {
constructor(deviceId, deviceOptions, getCurrentTime) {
super(deviceId, deviceOptions, getCurrentTime);
this._commandReceiver = this._defaultCommandReceiver.bind(this);
this._connected = false;
this._queueOverflow = false;
this._transitionHandler = new transitionHandler_1.InternalTransitionHandler();
this._retryTime = null;
this._currentState = { channels: {} };
if (deviceOptions.options) {
if (deviceOptions.commandReceiver)
this._commandReceiver = deviceOptions.commandReceiver;
}
this._doOnTime = new doOnTime_1.DoOnTime(() => {
return this.getCurrentTime();
}, doOnTime_1.SendMode.BURST, this._deviceOptions);
this.handleDoOnTime(this._doOnTime, 'CasparCG');
}
/**
* Initiates the connection with CasparCG through the ccg-connection lib and
* initializes CasparCG State library.
*/
async init(initOptions) {
this.initOptions = initOptions;
this._ccg = new casparcg_connection_1.BasicCasparCGAPI({
host: initOptions.host,
port: initOptions.port,
});
let firstConnect = true;
this._ccg.on('connect', () => {
this.makeReady(false) // always make sure timecode is correct, setting it can never do bad
.catch((e) => this.emit('error', 'casparCG.makeReady', e));
Promise.resolve()
.then(async () => {
// a "virgin server" was just restarted (so it is cleared & black).
// Otherwise it was probably just a loss of connection
const { error, request } = await this._ccg.executeCommand({ command: casparcg_connection_1.Commands.Info, params: {} });
if (error)
return true;
const response = await request;
const channelPromises = [];
const channelLength = response?.data?.['length'] ?? 0;
for (let i = 0; i < channelLength; i++) {
const obj = response.data[i];
if (!this._currentState.channels[i]) {
this._currentState.channels[obj.channel] = {
channelNo: obj.channel,
videoMode: this.getVideMode(obj),
fps: obj.frameRate,
layers: {},
};
}
if (this.deviceOptions.skipVirginCheck)
continue;
// Issue command
const { error, request } = await this._ccg.executeCommand({
command: casparcg_connection_1.Commands.InfoChannel,
params: { channel: obj.channel },
});
if (error) {
// We can't return here, as that will leave anything in channelPromises as potentially unhandled
channelPromises.push(Promise.reject('execute failed'));
continue;
}
channelPromises.push(request);
}
// Wait for all commands
const channelResults = await Promise.all(channelPromises);
// Resync if all channels are empty
for (const ch of channelResults) {
if (ch.data && ch.data.channel.layers.length > 0)
return false;
}
return true;
})
.catch((e) => {
this.emit('error', 'connect virgin check failed', e);
// Something failed, force the resync as glitching playback is better than black output
return true;
})
.then((doResync) => {
// Finally we can report it as connected
this._connected = true;
this._connectionChanged();
if (firstConnect || doResync) {
firstConnect = false;
this._currentState = { channels: {} };
this.clearStates();
this.emit('resyncStates');
}
})
.catch((e) => {
this.emit('error', 'connect state resync failed', e);
// Some unknwon error occured, report the connection as failed
this._connected = false;
this._connectionChanged();
});
});
this._ccg.on('disconnect', () => {
this._connected = false;
this._connectionChanged();
});
if (typeof initOptions.retryInterval === 'number' && initOptions.retryInterval >= 0) {
this._retryTime = initOptions.retryInterval || MEDIA_RETRY_INTERVAL;
this._retryTimeout = setTimeout(() => this._assertIntendedState(), this._retryTime);
}
return true;
}
/**
* Terminates the device safely such that things can be garbage collected.
*/
async terminate() {
this._doOnTime.dispose();
this._transitionHandler.terminate();
clearTimeout(this._retryTimeout);
return new Promise((resolve) => {
if (!this._ccg) {
resolve();
return;
}
else if (this._ccg.connected) {
this._ccg.once('disconnect', () => {
resolve();
this._ccg.removeAllListeners();
});
this._ccg.disconnect();
}
else {
this._ccg.removeAllListeners();
resolve();
}
});
}
/** Called by the Conductor a bit before a .handleState is called */
prepareForHandleState(newStateTime) {
// Clear any queued commands later than this time:
this._doOnTime.clearQueueNowAndAfter(newStateTime);
this.cleanUpStates(0, newStateTime);
}
/**
* Generates an array of CasparCG commands by comparing the newState against the oldState, or the current device state.
*/
handleState(newState, newMappings) {
super.onHandleState(newState, newMappings);
const previousStateTime = Math.max(this.getCurrentTime(), newState.time);
const oldCasparState = (this.getStateBefore(previousStateTime) || { state: { channels: {} } }).state;
const convertTrace = (0, lib_1.startTrace)(`device:convertState`, { deviceId: this.deviceId });
const newCasparState = this.convertStateToCaspar(newState, newMappings);
this.emit('timeTrace', (0, lib_1.endTrace)(convertTrace));
const diffTrace = (0, lib_1.startTrace)(`device:diffState`, { deviceId: this.deviceId });
const commandsToAchieveState = casparcg_state_1.CasparCGState.diffStatesOrderedCommands(oldCasparState, newCasparState, newState.time);
this.emit('timeTrace', (0, lib_1.endTrace)(diffTrace));
// clear any queued commands later than this time:
this._doOnTime.clearQueueNowAndAfter(previousStateTime);
// add the new commands to the queue:
this._addToQueue(commandsToAchieveState, newState.time);
// store the new state, for later use:
this.setState(newCasparState, newState.time);
}
/**
* Clear any scheduled commands after this time
* @param clearAfterTime
*/
clearFuture(clearAfterTime) {
this._doOnTime.clearQueueAfter(clearAfterTime);
}
get canConnect() {
return true;
}
get connected() {
// Returns connection status
return this._ccg ? this._ccg.connected : false;
}
get deviceType() {
return timeline_state_resolver_types_1.DeviceType.CASPARCG;
}
get deviceName() {
if (this._ccg) {
return 'CasparCG ' + this.deviceId + ' ' + this._ccg.host + ':' + this._ccg.port;
}
else {
return 'Uninitialized CasparCG ' + this.deviceId;
}
}
convertObjectToCasparState(mappings, layer, mapping, isForeground) {
let startTime = layer.instance.originalStart || layer.instance.start;
if (startTime === 0)
startTime = 1; // @todo: startTime === 0 will make ccg-state seek to the current time
const layerProps = layer;
const content = layer.content;
let stateLayer = null;
if (content.type === timeline_state_resolver_types_1.TimelineContentTypeCasparCg.MEDIA) {
const holdOnFirstFrame = !isForeground || layerProps.isLookahead;
const loopingPlayTime = content.loop && !content.seek && !content.inPoint && !content.length;
stateLayer = (0, lib_1.literal)({
id: layer.id,
layerNo: mapping.layer,
content: casparcg_state_1.LayerContentType.MEDIA,
media: content.file,
playTime: !holdOnFirstFrame && (content.noStarttime || loopingPlayTime) ? null : startTime,
pauseTime: holdOnFirstFrame ? startTime : content.pauseTime || null,
playing: !layerProps.isLookahead && (content.playing !== undefined ? content.playing : isForeground),
looping: content.loop,
seek: content.seek,
inPoint: content.inPoint,
length: content.length,
channelLayout: content.channelLayout,
clearOn404: true,
vfilter: content.videoFilter,
afilter: content.audioFilter,
});
// this.emitDebug(stateLayer)
}
else if (content.type === timeline_state_resolver_types_1.TimelineContentTypeCasparCg.IP) {
stateLayer = (0, lib_1.literal)({
id: layer.id,
layerNo: mapping.layer,
content: casparcg_state_1.LayerContentType.MEDIA,
media: content.uri,
channelLayout: content.channelLayout,
playTime: null,
playing: true,
seek: 0,
vfilter: content.videoFilter,
afilter: content.audioFilter,
});
}
else if (content.type === timeline_state_resolver_types_1.TimelineContentTypeCasparCg.INPUT) {
stateLayer = (0, lib_1.literal)({
id: layer.id,
layerNo: mapping.layer,
content: casparcg_state_1.LayerContentType.INPUT,
media: 'decklink',
input: {
device: content.device,
channelLayout: content.channelLayout,
format: content.deviceFormat,
},
playing: true,
playTime: null,
vfilter: content.videoFilter || content.filter,
afilter: content.audioFilter,
});
}
else if (content.type === timeline_state_resolver_types_1.TimelineContentTypeCasparCg.TEMPLATE) {
stateLayer = (0, lib_1.literal)({
id: layer.id,
layerNo: mapping.layer,
content: casparcg_state_1.LayerContentType.TEMPLATE,
media: content.name,
playTime: startTime || null,
playing: true,
templateType: content.templateType || 'html',
templateData: content.data,
cgStop: content.useStopCommand,
});
}
else if (content.type === timeline_state_resolver_types_1.TimelineContentTypeCasparCg.HTMLPAGE) {
stateLayer = (0, lib_1.literal)({
id: layer.id,
layerNo: mapping.layer,
content: casparcg_state_1.LayerContentType.HTMLPAGE,
media: (0, lib_1.interpolateTemplateStringIfNeeded)(content.url),
playTime: startTime || null,
playing: true,
});
}
else if (content.type === timeline_state_resolver_types_1.TimelineContentTypeCasparCg.ROUTE) {
if (content.mappedLayer) {
const routeMapping = mappings[content.mappedLayer];
if (routeMapping && routeMapping.deviceId === this.deviceId) {
content.channel = routeMapping.options.channel;
content.layer = routeMapping.options.layer;
}
}
stateLayer = (0, lib_1.literal)({
id: layer.id,
layerNo: mapping.layer,
content: casparcg_state_1.LayerContentType.ROUTE,
media: 'route',
route: {
channel: content.channel || 0,
layer: content.layer,
channelLayout: content.channelLayout,
},
mode: content.mode || undefined,
delay: content.delay || undefined,
playing: true,
playTime: null,
vfilter: content.videoFilter,
afilter: content.audioFilter,
});
}
else if (content.type === timeline_state_resolver_types_1.TimelineContentTypeCasparCg.RECORD) {
if (startTime) {
stateLayer = (0, lib_1.literal)({
id: layer.id,
layerNo: mapping.layer,
content: casparcg_state_1.LayerContentType.RECORD,
media: content.file,
encoderOptions: content.encoderOptions,
playing: true,
playTime: startTime,
});
}
}
// if no appropriate layer could be created, make it an empty layer
if (!stateLayer) {
const l = {
id: layer.id,
layerNo: mapping.layer,
content: casparcg_state_1.LayerContentType.NOTHING,
playing: false,
};
stateLayer = l;
} // now it holds that stateLayer is truthy
const baseContent = content;
if (baseContent.transitions) {
// add transitions to the layer obj
switch (baseContent.type) {
case timeline_state_resolver_types_1.TimelineContentTypeCasparCg.MEDIA:
case timeline_state_resolver_types_1.TimelineContentTypeCasparCg.IP:
case timeline_state_resolver_types_1.TimelineContentTypeCasparCg.TEMPLATE:
case timeline_state_resolver_types_1.TimelineContentTypeCasparCg.INPUT:
case timeline_state_resolver_types_1.TimelineContentTypeCasparCg.ROUTE:
case timeline_state_resolver_types_1.TimelineContentTypeCasparCg.HTMLPAGE: {
// create transition object
const media = stateLayer.media;
const transitions = {};
if (baseContent.transitions.inTransition) {
transitions.inTransition = new casparcg_state_1.Transition(baseContent.transitions.inTransition);
}
if (baseContent.transitions.outTransition) {
transitions.outTransition = new casparcg_state_1.Transition(baseContent.transitions.outTransition);
}
// todo - not a fan of this type assertion but think it's ok
stateLayer.media = new casparcg_state_1.TransitionObject(media, {
inTransition: transitions.inTransition,
outTransition: transitions.outTransition,
});
break;
}
default:
// create transition using mixer
break;
}
}
if ('mixer' in content && content.mixer) {
// add mixer properties
// just pass through values here:
const mixer = {};
_.each(content.mixer, (value, property) => {
mixer[property] = value;
});
stateLayer.mixer = mixer;
}
stateLayer.layerNo = mapping.layer;
return stateLayer;
}
/**
* Takes a timeline state and returns a CasparCG State that will work with the state lib.
* @param timelineState The timeline state to generate from.
*/
convertStateToCaspar(timelineState, mappings) {
const caspar = {
channels: {},
};
_.each(mappings, (foundMapping, layerName) => {
if (foundMapping &&
foundMapping.device === timeline_state_resolver_types_1.DeviceType.CASPARCG &&
foundMapping.deviceId === this.deviceId &&
_.has(foundMapping.options, 'channel') &&
_.has(foundMapping.options, 'layer')) {
const mapping = foundMapping;
mapping.options.channel = Number(mapping.options.channel) || 1;
mapping.options.layer = Number(mapping.options.layer) || 0;
// create a channel in state if necessary, or reuse existing channel
const channel = caspar.channels[mapping.options.channel] || { channelNo: mapping.options.channel, layers: {} };
channel.channelNo = mapping.options.channel;
channel.fps = this.initOptions ? this.initOptions.fps || 25 : 25;
caspar.channels[channel.channelNo] = channel;
let foregroundObj = timelineState.layers[layerName];
let backgroundObj = _.last(_.filter(timelineState.layers, (obj) => {
// Takes the last one, to be consistent with previous behaviour
const objExt = obj;
return !!objExt.isLookahead && objExt.lookaheadForLayer === layerName;
}));
// If lookahead is on the same layer, then ensure objects are treated as such
if (foregroundObj && foregroundObj.isLookahead) {
backgroundObj = foregroundObj;
foregroundObj = undefined;
}
// create layer of appropriate type
const foregroundStateLayer = foregroundObj
? this.convertObjectToCasparState(mappings, foregroundObj, mapping.options, true)
: undefined;
const backgroundStateLayer = backgroundObj
? this.convertObjectToCasparState(mappings, backgroundObj, mapping.options, false)
: undefined;
debug(`${layerName} (${mapping.options.channel}-${mapping.options.layer}): FG keys: ${Object.entries(foregroundStateLayer || {})
.map((e) => e[0] + ': ' + e[1])
.join(', ')}`);
debug(`${layerName} (${mapping.options.channel}-${mapping.options.layer}): BG keys: ${Object.entries(backgroundStateLayer || {})
.map((e) => e[0] + ': ' + e[1])
.join(', ')}`);
const merge = (o1, o2) => {
const o = {
...o1,
};
Object.entries(o2).forEach(([key, value]) => {
if (value !== undefined) {
o[key] = value;
}
});
return o;
};
if (foregroundStateLayer) {
const currentTemplateData = channel.layers[mapping.options.layer]
?.templateData;
const foregroundTemplateData = foregroundStateLayer?.templateData;
channel.layers[mapping.options.layer] = merge(channel.layers[mapping.options.layer], {
...foregroundStateLayer,
...(_.isObject(currentTemplateData) && _.isObject(foregroundTemplateData)
? { templateData: (0, lib_1.deepMerge)(currentTemplateData, foregroundTemplateData) }
: {}),
nextUp: backgroundStateLayer
? merge((channel.layers[mapping.options.layer] || {}).nextUp, (0, lib_1.literal)({
...backgroundStateLayer,
auto: false,
}))
: undefined,
});
}
else if (backgroundStateLayer) {
if (mapping.options.previewWhenNotOnAir) {
channel.layers[mapping.options.layer] = merge(channel.layers[mapping.options.layer], {
...channel.layers[mapping.options.layer],
...backgroundStateLayer,
playing: false,
});
}
else {
channel.layers[mapping.options.layer] = merge(channel.layers[mapping.options.layer], (0, lib_1.literal)({
id: `${backgroundStateLayer.id}_empty_base`,
layerNo: mapping.options.layer,
content: casparcg_state_1.LayerContentType.NOTHING,
playing: false,
nextUp: (0, lib_1.literal)({
...backgroundStateLayer,
auto: false,
}),
}));
}
}
}
});
return caspar;
}
/**
* Prepares the physical device for playout. If amcp scheduling is used this
* tries to sync the timecode. If {@code okToDestroyStuff === true} this clears
* all channels and resets our states.
* @param okToDestroyStuff Whether it is OK to restart the device
*/
async makeReady(okToDestroyStuff) {
// reset our own state(s):
if (okToDestroyStuff) {
await this.clearAllChannels();
}
}
async clearAllChannels() {
if (!this._ccg.connected) {
return {
result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error,
response: (0, lib_1.t)('Cannot restart CasparCG without a connection'),
};
}
const { error, request } = await this._ccg.executeCommand({ command: casparcg_connection_1.Commands.Info, params: {} });
if (error) {
return { result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error };
}
const response = await request;
if (!response?.data[0]) {
return { result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error };
}
await Promise.all(response.data.map(async (_, i) => {
await this._commandReceiver(this.getCurrentTime(), (0, lib_1.literal)({
command: casparcg_connection_1.Commands.Clear,
params: {
channel: i + 1,
},
}), 'clearAllChannels', '');
}));
this.clearStates();
this._currentState = { channels: {} };
response.data.forEach((obj) => {
this._currentState.channels[obj.channel] = {
channelNo: obj.channel,
videoMode: this.getVideMode(obj),
fps: obj.frameRate,
layers: {},
};
});
this.emit('resetResolver');
return {
result: timeline_state_resolver_types_1.ActionExecutionResultCode.Ok,
};
}
async executeAction(actionId, payload) {
switch (actionId) {
case timeline_state_resolver_types_1.CasparCGActions.ClearAllChannels:
return this.clearAllChannels();
case timeline_state_resolver_types_1.CasparCGActions.RestartServer:
return this.restartCasparCG();
case timeline_state_resolver_types_1.CasparCGActions.ListMedia:
return this.listMedia(payload);
default:
return (0, lib_1.actionNotFoundMessage)(actionId);
}
}
/**
* Attemps to restart casparcg over the HTTP API provided by CasparCG launcher.
*/
async restartCasparCG() {
if (!this.initOptions) {
return { result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error, response: (0, lib_1.t)('CasparCGDevice._connectionOptions is not set!') };
}
if (!this.initOptions.launcherHost) {
return { result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error, response: (0, lib_1.t)('CasparCGDevice: config.launcherHost is not set!') };
}
if (!this.initOptions.launcherPort) {
return { result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error, response: (0, lib_1.t)('CasparCGDevice: config.launcherPort is not set!') };
}
if (!this.initOptions.launcherProcess) {
return {
result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error,
response: (0, lib_1.t)('CasparCGDevice: config.launcherProcess is not set!'),
};
}
const url = `http://${this.initOptions?.launcherHost}:${this.initOptions?.launcherPort}/processes/${this.initOptions?.launcherProcess}/restart`;
return got_1.default
.post(url, {
timeout: {
request: 5000, // Arbitary, long enough for realistic scenarios
},
})
.then((response) => {
if (response.statusCode === 200) {
return { result: timeline_state_resolver_types_1.ActionExecutionResultCode.Ok };
}
else {
return {
result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error,
response: (0, lib_1.t)('Bad reply: [{{statusCode}}] {{body}}', {
statusCode: response.statusCode,
body: response.body,
}),
};
}
})
.catch((error) => {
return {
result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error,
response: (0, lib_1.t)('{{message}}', {
message: error.toString(),
}),
};
});
}
async listMedia(query = {}) {
const result = await this._ccg.executeCommand((0, lib_1.literal)({
command: casparcg_connection_1.Commands.Cls,
params: query,
}));
if (result.error)
return {
result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error,
response: (0, lib_1.t)(`Error message from CasparCG: {{message}}`, { message: `${result.error}` }),
};
const request = await result.request;
if (request.responseCode === 200) {
return {
result: timeline_state_resolver_types_1.ActionExecutionResultCode.Ok,
resultData: request.data,
};
}
else {
return {
result: timeline_state_resolver_types_1.ActionExecutionResultCode.Error,
response: (0, lib_1.t)(`Error code {{code}} from CasparCG`, { code: request.responseCode }),
};
}
}
getStatus() {
let statusCode = device_1.StatusCode.GOOD;
const messages = [];
if (statusCode === device_1.StatusCode.GOOD) {
if (!this._connected) {
statusCode = device_1.StatusCode.BAD;
messages.push(`CasparCG disconnected`);
}
}
if (this._queueOverflow) {
statusCode = device_1.StatusCode.BAD;
messages.push('Command queue overflow: CasparCG server has to be restarted');
}
return {
statusCode: statusCode,
messages: messages,
active: this.isActive,
};
}
/**
* Use either AMCP Command Scheduling or the doOnTime to execute commands at
* {@code time}.
* @param commandsToAchieveState Commands to be added to queue
* @param time Point in time to send commands at
*/
_addToQueue(commandsToAchieveState, time) {
_.each(commandsToAchieveState, (cmd) => {
this._doOnTime.queue(time, undefined, async (c) => {
return this._commandReceiver(time, c.command, c.cmd.context.context, c.cmd.context.layerId);
}, { command: { command: cmd.command, params: cmd.params }, cmd: cmd });
});
}
/**
* Sends a command over a casparcg-connection instance
* @param time deprecated
* @param cmd Command to execute
*/
async _defaultCommandReceiver(time, cmd, context, timelineObjId) {
// do no retry while we are sending commands, instead always retry closely after:
if (!context.match(/\[RETRY\]/i)) {
clearTimeout(this._retryTimeout);
if (this._retryTime)
this._retryTimeout = setTimeout(() => this._assertIntendedState(), this._retryTime);
}
const cwc = {
context,
timelineObjId,
command: JSON.stringify(cmd),
};
this.emitDebug(cwc);
const { request, error } = await this._ccg.executeCommand(cmd);
if (error) {
this.emit('commandError', error, cwc);
}
try {
const response = await request;
// I forgot what this means.. oh well... todo
if (!response)
return;
this._changeTrackedStateFromCommand(cmd, response, time);
if (response.responseCode === 504 && !this._queueOverflow) {
this._queueOverflow = true;
this._connectionChanged();
}
else if (this._queueOverflow) {
this._queueOverflow = false;
this._connectionChanged();
}
if (response.responseCode >= 400) {
// this is an error code:
let errorString = `${response.responseCode} ${cmd.command} ${response.type}: ${response.type}`;
if (Object.keys(cmd.params).length) {
errorString += ' ' + JSON.stringify(cmd.params);
}
this.emit('commandError', new Error(errorString), cwc);
}
}
catch (e) {
// This shouldn't really happen
this.emit('commandError', Error('Command not sent: ' + e), cwc);
}
}
_changeTrackedStateFromCommand(command, response, time) {
// Ensure this is for a channel and layer
if (!('channel' in command.params) || command.params.channel === undefined)
return;
if (!('layer' in command.params) || command.params.layer === undefined)
return;
if (response.responseCode < 300 && // TODO - maybe we accept every code except 404?
response.command.match(/Loadbg|Play|Load|Clear|Stop|Resume/i)) {
const currentExpectedState = this.getState(time);
if (currentExpectedState) {
const confirmedState = this._currentState;
const expectedChannelState = currentExpectedState.state.channels[command.params.channel];
if (expectedChannelState) {
let confirmedChannelState = confirmedState.channels[command.params.channel];
if (!confirmedState.channels[command.params.channel]) {
confirmedChannelState = confirmedState.channels[command.params.channel] = {
channelNo: expectedChannelState.channelNo,
fps: expectedChannelState.fps || 0,
videoMode: expectedChannelState.videoMode || null,
layers: {},
};
}
// copy into the trackedState
switch (command.command) {
case casparcg_connection_1.Commands.Play:
case casparcg_connection_1.Commands.Load:
if (!('clip' in command.params) && !confirmedChannelState.layers[command.params.layer]?.nextUp) {
// Ignore, no clip was loaded in confirmedChannelState
}
else {
// a play/load command without parameters (channel/layer) is only succesful if the nextUp worked
// a play/load command with params can always be accepted
confirmedChannelState.layers[command.params.layer] = {
...expectedChannelState.layers[command.params.layer],
nextUp: undefined, // a play command always clears nextUp
};
}
break;
case casparcg_connection_1.Commands.Loadbg:
// only loadbg can set nextUp and nextUp can only be set by loadbg
confirmedChannelState.layers[command.params.layer] = {
...confirmedChannelState.layers[command.params.layer],
nextUp: expectedChannelState.layers[command.params.layer]?.nextUp,
};
break;
case casparcg_connection_1.Commands.Stop:
if (confirmedChannelState.layers[command.params.layer]?.nextUp?.auto) {
// auto next + stop means bg -> fg => nextUp cleared
confirmedChannelState.layers[command.params.layer] = {
...expectedChannelState.layers[command.params.layer],
nextUp: undefined, // auto next + stop means bg -> fg => nextUp cleared
};
}
else {
// stop does not affect nextup
confirmedChannelState.layers[command.params.layer] = {
...expectedChannelState.layers[command.params.layer],
nextUp: confirmedChannelState.layers[command.params.layer]?.nextUp,
};
}
break;
case casparcg_connection_1.Commands.Resume:
// resume does not affect nextup
confirmedChannelState.layers[command.params.layer] = {
...expectedChannelState.layers[command.params.layer],
nextUp: confirmedChannelState.layers[command.params.layer]?.nextUp,
};
break;
case casparcg_connection_1.Commands.Clear:
// Remove both the background and foreground
delete confirmedChannelState.layers[command.params.layer];
break;
default: {
// Never hit
// const _a: never = command.params.name
break;
}
}
}
}
}
}
/**
* This function takes the current timeline-state, and diffs it with the known
* CasparCG state. If any media has failed to load, it will create a diff with
* the intended (timeline) state and that command will be executed.
*/
_assertIntendedState() {
if (this._retryTime) {
this._retryTimeout = setTimeout(() => this._assertIntendedState(), this._retryTime);
}
const tlState = this.getState(this.getCurrentTime());
if (!tlState)
return; // no state implies any state is correct
const ccgState = tlState.state;
const diff = casparcg_state_1.CasparCGState.diffStates(this._currentState, ccgState, this.getCurrentTime());
const cmd = [];
for (const layer of diff) {
// filter out media commands
for (let i = 0; i < layer.cmds.length; i++) {
if (
// todo - shall we pass decklinks etc. as well?
layer.cmds[i].command === casparcg_connection_1.Commands.Loadbg ||
layer.cmds[i].command === casparcg_connection_1.Commands.Load ||
(layer.cmds[i].command === casparcg_connection_1.Commands.Play && 'clip' in layer.cmds[i].params)) {
layer.cmds[i].context.context += ' [RETRY]';
cmd.push(layer.cmds[i]);
}
}
}
if (cmd.length > 0) {
this._addToQueue(cmd, this.getCurrentTime());
}
}
_connectionChanged() {
this.emit('connectionChanged', this.getStatus());
}
getVideMode(info) {
return `${info.format}${info.interlaced ? 'I' : 'P'}${info.frameRate}`;
}
}
exports.CasparCGDevice = CasparCGDevice;
//# sourceMappingURL=index.js.map