timeline-state-resolver
Version:
Have timeline, control stuff
601 lines • 26.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.setSoftJumpWaitTime = exports.QuantelManager = void 0;
const eventemitter3_1 = require("eventemitter3");
const timeline_state_resolver_types_1 = require("timeline-state-resolver-types");
const _ = require("underscore");
const waitGroup_1 = require("../../waitGroup");
let SOFT_JUMP_WAIT_TIME = 250; // Is a constant, but can be changed during unit tests
const DEFAULT_FPS = 25; // frames per second
const JUMP_ERROR_MARGIN = 10; // frames
class QuantelManager extends eventemitter3_1.EventEmitter {
constructor(_quantel, getCurrentTime, options) {
super();
this._quantel = _quantel;
this.getCurrentTime = getCurrentTime;
this.options = options;
this._quantelState = {
port: {},
};
this._cache = new Cache();
this._waitWithPorts = new waitGroup_1.WaitGroup();
this._retryLoadFragmentsTimeout = {};
this._failedAction = {};
this.waitingForReleaseChannel = new Map(); // maps channel to Promise
this._quantel.on('error', (...args) => this.emit('error', ...args));
this._quantel.on('debug', (...args) => this.emit('debug', ...args));
}
async setupPort(cmd) {
const trackedPort = this._quantelState.port[cmd.portId];
// Check if the port is already set up
if (!trackedPort || trackedPort.channel !== cmd.channel) {
// Before doing anything, wait for any releasePort to finish:
await (this.waitingForReleaseChannel.get(cmd.channel) || Promise.resolve());
let port = null;
// Setup a port and connect it to a channel
try {
port = await this._quantel.getPort(cmd.portId);
}
catch (e) {
// If the GET fails, it might be something unknown wrong.
// A temporary workaround is to send a delete on that port and try again, it might work.
try {
await this._quantel.releasePort(cmd.portId);
}
catch {
// ignore any errors
}
// Try again:
port = await this._quantel.getPort(cmd.portId);
}
if (port) {
try {
// port already exists, release it first:
await this._quantel.releasePort(cmd.portId);
}
catch (e) {
// we should still try to create the port even if we can't release the old one
this.emit('warning', `setupPort release failed: ${e.toString()}`);
}
}
await this._quantel.createPort(cmd.portId, cmd.channel);
// Store to the local tracking state:
this._quantelState.port[cmd.portId] = {
loadedFragments: {},
offset: -1,
playing: false,
jumpOffset: null,
scheduledStop: null,
channel: cmd.channel,
};
}
}
async releasePort(cmd) {
try {
const channel = this._quantelState.port[cmd.portId].channel;
{
// Before doing anything, wait for an existing releasePort to finish:
const existingRelease = this.waitingForReleaseChannel.get(channel);
if (existingRelease)
await existingRelease;
}
const p = this._quantel.releasePort(cmd.portId);
// Create a promise for others to wait on, that will never reject
const waitP = p.catch().then(() => {
this.waitingForReleaseChannel.delete(channel);
});
this.waitingForReleaseChannel.set(channel, waitP);
// Wait for the release
await p;
}
catch (e) {
if (e.status !== 404) {
// releasing a non-existent port is OK
throw e;
}
}
// Delete the local tracking state:
delete this._quantelState.port[cmd.portId];
}
async tryLoadClipFragments(cmd, fromRetry) {
if (this._retryLoadFragmentsTimeout[cmd.portId]) {
clearTimeout(this._retryLoadFragmentsTimeout[cmd.portId]);
delete this._retryLoadFragmentsTimeout[cmd.portId];
}
try {
await this.loadClipFragments(cmd);
if (fromRetry) {
// The loading seemed to work now.
// Check if there also is a queued action for this:
const failedAction = this._failedAction[cmd.portId];
if (failedAction) {
delete this._failedAction[cmd.portId];
this.prepareClipJump(failedAction.cmd, failedAction.action).catch((err) => this.emit('error', err));
}
}
}
catch (err) {
if ((err + '').match(/not found/i)) {
// It seems like the clip doesn't exist.
// Try again some time later, maybe it has appeared by then?
this._retryLoadFragmentsTimeout[cmd.portId] = setTimeout(() => {
this.tryLoadClipFragments(cmd, true).catch((fragErr) => this.emit('error', fragErr));
}, 10 * 1000); // 10 seconds
}
else {
throw err;
}
}
}
async loadClipFragments(cmd) {
const trackedPort = this.getTrackedPort(cmd.portId);
const server = await this.getServer();
let clipId = 0;
try {
clipId = await this.getClipId(cmd.clip);
}
catch (e) {
if ((e + '').match(/not found/i)) {
// The clip was not found
if (this.options.allowCloneClips) {
// Try to clone the clip from another server:
if (!server.pools)
throw new Error(`server.pools not set!`);
// find another clip
const foundClips = this.filterClips(await this.searchForClips(cmd.clip), undefined);
const clipToCloneFrom = _.first(this.prioritizeClips(foundClips));
if (clipToCloneFrom) {
// Try to copy to each of the server pools, break on first succeeded
let copyCreated = false;
let lastError;
for (const pool of server.pools) {
try {
const cloneResult = await this._quantel.copyClip(undefined, clipToCloneFrom.ClipID, pool, 8, true);
clipId = cloneResult.copyID; // new clip id
copyCreated = true;
break;
}
catch (e) {
lastError = e;
continue;
}
}
if (!copyCreated) {
throw lastError || new Error(`Unable to copy clip ${clipToCloneFrom.ClipID} for unknown reasons`);
}
}
else
throw e;
}
else
throw e;
}
else
throw e;
}
// let clipId = await this.getClipId(cmd.clip)
const clipData = await this._quantel.getClip(clipId);
if (!clipData)
throw new Error(`Clip ${clipId} not found`);
if (!clipData.PoolID)
throw new Error(`Clip ${clipData.ClipID} missing PoolID`);
// Check that the clip is present on the server:
if (!(server.pools || []).includes(clipData.PoolID)) {
throw new Error(`Clip "${clipData.ClipID}" PoolID ${clipData.PoolID} not found on right server (${server.ident})`);
}
const useInOutPoints = !!(cmd.clip.inPoint || cmd.clip.length);
/** milliseconds */
const inPoint = cmd.clip.inPoint;
/** milliseconds */
const length = cmd.clip.length;
/** In point [frames] */
const inPointFrames = (inPoint
? Math.round((inPoint * DEFAULT_FPS) / 1000) // todo: handle fps, get it from clip?
: 0) || 0;
/** Duration [frames] */
let lengthFrames = 0;
if (length) {
lengthFrames = Math.round((length * DEFAULT_FPS) / 1000); // todo: handle fps, get it from clip?
}
if (!lengthFrames) {
const clipLength = parseInt(clipData.Frames, 10) || 0;
if (inPoint) {
lengthFrames = clipLength - inPointFrames; // THe remaining length of the clip
}
else {
lengthFrames = clipLength;
}
}
const outPointFrames = inPointFrames + lengthFrames;
let portInPoint;
let portOutPoint;
// Check if the fragments are already loaded on the port?
const loadedFragments = trackedPort.loadedFragments[clipId];
if (loadedFragments && loadedFragments.inPoint === inPointFrames && loadedFragments.outPoint === outPointFrames) {
// Reuse the already loaded fragment:
portInPoint = loadedFragments.portInPoint;
// portOutPoint = loadedFragments.portOutPoint
}
else {
// Fetch fragments of clip:
const fragmentsInfo = await (useInOutPoints
? this._quantel.getClipFragments(clipId, inPointFrames, outPointFrames)
: this._quantel.getClipFragments(clipId));
// Check what the end-frame of the port is:
const portStatus = await this._quantel.getPort(cmd.portId);
if (!portStatus)
throw new Error(`Port ${cmd.portId} not found`);
// Load the fragments onto Port:
portInPoint = portStatus.endOfData || 0;
const newPortStatus = await this._quantel.loadFragmentsOntoPort(cmd.portId, fragmentsInfo.fragments, portInPoint);
if (!newPortStatus)
throw new Error(`Port ${cmd.portId} not found after loading fragments`);
// Calculate the end of data of the fragments:
portOutPoint =
portInPoint +
(fragmentsInfo.fragments
.filter((fragment) => fragment.type === 'VideoFragment' && // Only use video, so that we don't risk ending at a black frame
fragment.trackNum === 0 // < 0 are historic data (not used for automation), 0 is the normal, playable video track, > 0 are extra channels, such as keys
)
.reduce((prev, current) => (prev > current.finish ? prev : current.finish), 0) -
1); // newPortStatus.endOfData - 1
// Store a reference to the beginning of the fragments:
trackedPort.loadedFragments[clipId] = {
portInPoint: portInPoint,
portOutPoint: portOutPoint,
inPoint: inPointFrames,
outPoint: outPointFrames,
};
}
// Prepare the jump?
const timeLeftToPlay = cmd.timeOfPlay - this.getCurrentTime();
if (cmd.allowedToPrepareJump && timeLeftToPlay > 0) {
// We have time to prepare the jump
if (portInPoint > 0 && trackedPort.scheduledStop === null) {
// Since we've now added fragments to the end of the port timeline, we should make sure it'll stop at the previous end
await this._quantel.portStop(cmd.portId, portInPoint - 1);
trackedPort.scheduledStop = portInPoint - 1;
}
await this._quantel.portPrepareJump(cmd.portId, portInPoint);
// Store the jump in the tracked state:
trackedPort.jumpOffset = portInPoint;
}
}
async playClip(cmd) {
await this.tryPrepareClipJump(cmd, 'play');
}
async pauseClip(cmd) {
await this.tryPrepareClipJump(cmd, 'pause');
}
async clearClip(cmd) {
// Fetch tracked reference to the loaded clip:
const trackedPort = this.getTrackedPort(cmd.portId);
if (cmd.transition) {
if (cmd.transition.type === timeline_state_resolver_types_1.QuantelTransitionType.DELAY) {
if (await this.waitWithPort(cmd.portId, cmd.transition.delay)) {
// at this point, the wait aws aborted by someone else. Do nothing then.
return;
}
}
}
// Reset the port (this will clear all fragments and reset playhead)
await this._quantel.resetPort(cmd.portId);
trackedPort.loadedFragments = {};
trackedPort.offset = -1;
trackedPort.playing = false;
trackedPort.jumpOffset = null;
trackedPort.scheduledStop = null;
}
async tryPrepareClipJump(cmd, alsoDoAction) {
delete this._failedAction[cmd.portId];
try {
return await this.prepareClipJump(cmd, alsoDoAction);
}
catch (err) {
if (this._retryLoadFragmentsTimeout[cmd.portId]) {
// It looks like there was an issue with loading fragments,
// that's probably why we got an error as well.
if ((err + '').match(/not found/i)) {
// Store the failed action, it'll be run whenever the fragments has been loaded later:
this._failedAction[cmd.portId] = {
action: alsoDoAction,
cmd: cmd,
};
}
else
throw err;
}
else
throw err;
}
}
async prepareClipJump(cmd, alsoDoAction) {
// Fetch tracked reference to the loaded clip:
const trackedPort = this.getTrackedPort(cmd.portId);
if (cmd.transition) {
if (cmd.transition.type === timeline_state_resolver_types_1.QuantelTransitionType.DELAY) {
if (await this.waitWithPort(cmd.portId, cmd.transition.delay)) {
// at this point, the wait aws aborted by someone else. Do nothing then.
return;
}
}
}
const clipId = await this.getClipId(cmd.clip);
const loadedFragments = trackedPort.loadedFragments[clipId];
if (!loadedFragments) {
// huh, the fragments hasn't been loaded
throw new Error(`Fragments of clip ${clipId} wasn't loaded`);
}
const clipFps = DEFAULT_FPS; // todo: handle fps, get it from clip?
const jumpToOffset = Math.floor(loadedFragments.portInPoint +
(cmd.clip.playTime
? (Math.max(0, (cmd.clip.pauseTime || this.getCurrentTime()) - cmd.clip.playTime) * clipFps) / 1000
: 0));
this.emit('warning', `prepareClipJump: cmd=${JSON.stringify(cmd)}: ${alsoDoAction}: clipId=${clipId}: jumpToOffset=${jumpToOffset}: trackedPort=${JSON.stringify(trackedPort)}`);
if ((jumpToOffset === trackedPort.offset && trackedPort.playing === false) || // On request to play clip again, prepare jump // We're already there
// TODO: what situation is this for??
(alsoDoAction === 'play' &&
// trackedPort.offset &&
trackedPort.playing === false &&
jumpToOffset > trackedPort.offset &&
jumpToOffset - trackedPort.offset < JUMP_ERROR_MARGIN)
// We're probably a bit late, just start playing
) {
// do nothing
}
else {
// We've determined that we're not on the correct frame
if (trackedPort.jumpOffset !== null &&
Math.abs(trackedPort.jumpOffset - jumpToOffset) > JUMP_ERROR_MARGIN // "the prepared jump is still valid"
// || trackedPort.playing === true // Likely request to play clip again
) {
// It looks like the stored jump is no longer valid
// Invalidate stored jump:
trackedPort.jumpOffset = null;
}
// Jump the port playhead to the correct place
if (trackedPort.jumpOffset !== null) {
// Good, there is a prepared jump
if (alsoDoAction === 'pause') {
// Pause the playback:
await this._quantel.portStop(cmd.portId);
trackedPort.scheduledStop = null;
trackedPort.playing = false;
}
// Trigger the jump:
await this._quantel.portTriggerJump(cmd.portId);
trackedPort.offset = trackedPort.jumpOffset;
trackedPort.jumpOffset = null;
}
else {
// No jump has been prepared
if (cmd.mode === timeline_state_resolver_types_1.QuantelControlMode.QUALITY) {
// Prepare a soft jump:
await this._quantel.portPrepareJump(cmd.portId, jumpToOffset);
trackedPort.jumpOffset = jumpToOffset;
if (alsoDoAction === 'pause') {
// Pause the playback:
await this._quantel.portStop(cmd.portId);
trackedPort.scheduledStop = null;
trackedPort.playing = false;
// Allow the server some time to load the clip:
await this.wait(SOFT_JUMP_WAIT_TIME); // This is going to give the
}
else {
// Allow the server some time to load the clip:
await this.wait(SOFT_JUMP_WAIT_TIME); // This is going to give the
}
// Trigger the jump:
await this._quantel.portTriggerJump(cmd.portId);
trackedPort.offset = trackedPort.jumpOffset;
trackedPort.jumpOffset = null;
}
else {
// cmd.mode === QuantelControlMode.SPEED
// Just do a hard jump:
await this._quantel.portHardJump(cmd.portId, jumpToOffset);
trackedPort.offset = jumpToOffset;
trackedPort.playing = false;
// trackedPort.jumpOffset = null TODO:
}
}
}
if (alsoDoAction === 'play') {
// Start playing:
await this._quantel.portPlay(cmd.portId);
await this.wait(60);
// Check if the play actually succeeded:
const portStatus = await this._quantel.getPort(cmd.portId);
if (!portStatus) {
// oh, something's gone very wrong
throw new Error(`Quantel: After play, port doesn't exist anymore`);
}
else if (!portStatus.status.match(/playing/i)) {
// The port didn't seem to have started playing, let's retry a few more times:
this.emit('warning', `quantelRecovery: port didn't play`);
this.emit('warning', portStatus);
for (let i = 0; i < 3; i++) {
await this.wait(20);
await this._quantel.portPlay(cmd.portId);
await this.wait(60 + i * 200); // Wait progressively longer times before trying again:
const portStatus = await this._quantel.getPort(cmd.portId);
if (portStatus && portStatus.status.match(/playing/i)) {
// it has started playing, all good!
this.emit('warning', `quantelRecovery: port started playing again, on try ${i}`);
break;
}
else {
this.emit('warning', `quantelRecovery: try ${i}, no luck trying again..`);
this.emit('warning', portStatus);
}
}
}
trackedPort.scheduledStop = null;
trackedPort.playing = true;
trackedPort.jumpOffset = null; // As a safety precaution, remove any knowledge of any prepared jump, another preparation will be triggered on any following commands.
// Schedule the port to stop at the last frame of the clip
if (loadedFragments.portOutPoint) {
await this._quantel.portStop(cmd.portId, loadedFragments.portOutPoint);
trackedPort.scheduledStop = loadedFragments.portOutPoint;
}
}
else if (alsoDoAction === 'pause' && trackedPort.playing) {
await this._quantel.portHardJump(cmd.portId, jumpToOffset);
trackedPort.offset = jumpToOffset;
trackedPort.playing = false;
trackedPort.jumpOffset = null; // As a safety precaution, remove any knowledge of any prepared jump, another preparation will be triggered on any following commands.
}
}
getTrackedPort(portId) {
const trackedPort = this._quantelState.port[portId];
if (!trackedPort) {
// huh, it looks like the port hasn't been created yet.
// This is strange, it should have been created by a previously run SETUPPORT
throw new Error(`Port ${portId} missing in tracked quantel state`);
}
return trackedPort;
}
async getServer() {
const server = await this._quantel.getServer();
if (!server)
throw new Error(`Quantel server ${this._quantel.serverId} not found`);
if (!server.pools)
throw new Error(`Server ${server.ident} has no .pools`);
if (!server.pools.length)
throw new Error(`Server ${server.ident} has an empty .pools array`);
return server;
}
async getClipId(clip) {
let clipId = clip.clipId;
if (!clipId && clip.guid) {
clipId = await this._cache.getSet(`clip.guid.${clip.guid}.clipId`, async () => {
const server = await this.getServer();
// Look up the clip:
const foundClips = this.filterClips(await this.searchForClips(clip), server);
const foundClip = _.first(this.prioritizeClips(foundClips));
if (!foundClip)
throw new Error(`Clip with GUID "${clip.guid}" not found on server (${server.ident})`);
return foundClip.ClipID;
});
}
else if (!clipId && clip.title) {
clipId = await this._cache.getSet(`clip.title.${clip.title}.clipId`, async () => {
const server = await this.getServer();
// Look up the clip:
const foundClips = this.filterClips(await this.searchForClips(clip), server);
const foundClip = _.first(this.prioritizeClips(foundClips));
if (!foundClip)
throw new Error(`Clip with Title "${clip.title}" not found on server (${server.ident})`);
return foundClip.ClipID;
});
}
if (!clipId)
throw new Error(`Unable to determine clipId for clip "${clip.title || clip.guid}"`);
return clipId;
}
filterClips(clips, server) {
return _.filter(clips, (clip) => typeof clip.PoolID === 'number' &&
parseInt(clip.Frames, 10) > 0 && // "Placeholder clips" does not have any Frames
(!server || (server.pools || []).indexOf(clip.PoolID) !== -1) // If present in any of the pools of the server
// From Media-Manager:
// clip.Completed !== null &&
// clip.Completed.length > 0 // Note from Richard: Completed might not necessarily mean that it's completed on the right server
);
}
prioritizeClips(clips) {
// Sort the clips, so that the most likely to use is first.
return clips.sort((a, b // Sort Created dates into reverse order
) => new Date(b.Created).getTime() - new Date(a.Created).getTime());
}
async searchForClips(clip) {
if (clip.guid) {
return this._quantel.searchClip({
ClipGUID: `"${clip.guid}"`,
});
}
else if (clip.title) {
return this._quantel.searchClip({
Title: `"${clip.title}"`,
});
}
else {
throw new Error(`Unable to search for clip "${clip.title || clip.guid}"`);
}
}
async wait(time) {
return new Promise((resolve) => {
setTimeout(resolve, time);
});
}
clearAllWaitWithPort(portId) {
this._waitWithPorts.clearAllForKey(portId);
}
/**
* Returns true if the wait was cleared from someone else
*/
async waitWithPort(portId, delay) {
return this._waitWithPorts.waitOnKey(portId, delay);
}
}
exports.QuantelManager = QuantelManager;
class Cache {
constructor() {
this.data = {};
this.callCount = 0;
}
set(key, value, ttl = 30000) {
this.data[key] = {
endTime: Date.now() + ttl,
value: value,
};
this.callCount++;
if (this.callCount > 100) {
this.callCount = 0;
this._triggerClean();
}
return value;
}
get(key) {
const o = this.data[key];
if (o && (o.endTime || 0) >= Date.now())
return o.value;
}
exists(key) {
const o = this.data[key];
return o && (o.endTime || 0) >= Date.now();
}
getSet(key, fcn, ttl) {
if (this.exists(key)) {
return this.get(key);
}
else {
const value = fcn();
if (value && _.isObject(value) && _.isFunction(value['then'])) {
// value is a promise
return Promise.resolve(value).then((value) => {
return this.set(key, value, ttl);
});
}
else {
return this.set(key, value, ttl);
}
}
}
_triggerClean() {
setTimeout(() => {
_.each(this.data, (o, key) => {
if ((o.endTime || 0) < Date.now()) {
delete this.data[key];
}
});
}, 1);
}
}
/**
* USED IN UNIT TESTS ONLY
*/
function setSoftJumpWaitTime(time) {
SOFT_JUMP_WAIT_TIME = time;
}
exports.setSoftJumpWaitTime = setSoftJumpWaitTime;
//# sourceMappingURL=connection.js.map