mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-03-24 16:51:47 -04:00
- Integrate EventStream.js into montagereview.js for persistent MJPEG streaming in replay mode (speeds >= 1x), falling back to per-frame mode=single for speeds < 1x where zms lacks slow-motion support - Add EventStream recovery logic: detect zms death via AJAX failures, img.onerror, and error responses; auto-restart with new connkey and exponential backoff (max 5 retries) - Fix zms crash on bulk/interpolated frames: add stat() check in sendFrame() for SaveJPEGs & 1 path, fall through to ffmpeg_input when JPEG file doesn't exist on disk; send "No frame available" text frame instead of terminating when no source available - Center monitor canvases: text-align in CSS for scale mode, horizontal offset calculation in maxfit2() for fit mode - Reduce console noise: comment out high-frequency debug logs, convert error-path logs to console.warn/error - Fix ESLint sourceType to "script" for traditional non-module JS files, resolving false-positive no-unused-vars on global functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
498 lines
16 KiB
JavaScript
498 lines
16 KiB
JavaScript
"use strict";
|
|
|
|
/**
|
|
* EventStream - Manages a persistent zms MJPEG connection for event playback.
|
|
*
|
|
* Mirrors the MonitorStream.js constructor-function pattern. Frames arrive via
|
|
* a hidden <img> receiving a multipart MJPEG stream from zms and are drawn to
|
|
* a caller-supplied <canvas> on each img.onload.
|
|
*
|
|
* Commands (seek, pause, play, rate changes) are sent to zms over its existing
|
|
* command-socket protocol via AJAX, exactly like MonitorStream and event.js.
|
|
*
|
|
* @param {Object} config
|
|
* @param {number} config.monitorId
|
|
* @param {number} config.monitorWidth - Native monitor width
|
|
* @param {number} config.monitorHeight - Native monitor height
|
|
* @param {string} config.url - URL to index.php (for command AJAX)
|
|
* @param {string} config.url_to_zms - PathToZMS base URL
|
|
* @param {HTMLCanvasElement} config.canvas
|
|
* @param {number} [config.scale=100] - Scale percentage
|
|
*/
|
|
function EventStream(config) {
|
|
this.monitorId = config.monitorId;
|
|
this.monitorWidth = config.monitorWidth;
|
|
this.monitorHeight = config.monitorHeight;
|
|
this.url = config.url;
|
|
this.url_to_zms = config.url_to_zms;
|
|
this.canvas = config.canvas;
|
|
this.scale = config.scale ? parseInt(config.scale) : 100;
|
|
|
|
this.connKey = null;
|
|
this.img = null;
|
|
this.started = false;
|
|
this.paused = false;
|
|
this.currentEventId = null;
|
|
this.rate = 100;
|
|
this.status = null;
|
|
this.streamCmdTimer = null;
|
|
this.ajaxQueue = null;
|
|
this.rafId = null;
|
|
|
|
// Recovery state
|
|
this.consecutiveErrors = 0;
|
|
this.maxRecoveryAttempts = 5;
|
|
this.recoveryDelay = 1000; // ms, doubles on each retry
|
|
this.recoveryTimer = null;
|
|
this.lastOptions = null; // saved for restart after recovery
|
|
|
|
// Callbacks — set by the consumer
|
|
this.onStatus = null;
|
|
this.onError = null;
|
|
|
|
// How often to poll zms for status (ms). Use the global if available,
|
|
// otherwise fall back to a sensible default.
|
|
this.statusInterval = (typeof statusRefreshTimeout !== 'undefined')
|
|
? statusRefreshTimeout
|
|
: (typeof streamTimeout !== 'undefined') ? streamTimeout : 2000;
|
|
|
|
// Command parameters template — matches MonitorStream / event.js protocol
|
|
this.streamCmdParms = {
|
|
view: 'request',
|
|
request: 'stream',
|
|
connkey: null
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// connKey generation (identical to MonitorStream)
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.genConnKey = function() {
|
|
return (Math.floor((Math.random() * 999999) + 1))
|
|
.toLocaleString('en-US', {minimumIntegerDigits: 6, useGrouping: false});
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// start(eventId, options) — Begin streaming an event
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {number|string} eventId
|
|
* @param {Object} [options]
|
|
* @param {number} [options.time] - Epoch seconds to start at
|
|
* @param {number} [options.frame=1] - Frame ID to start at
|
|
* @param {number} [options.rate=100] - Playback rate (100 = 1x)
|
|
* @param {string} [options.replay='none']
|
|
* @param {number} [options.maxfps] - Max FPS for the stream
|
|
*/
|
|
this.start = function(eventId, options) {
|
|
options = options || {};
|
|
this.currentEventId = eventId;
|
|
this.rate = (options.rate !== undefined) ? options.rate : 100;
|
|
this.paused = false;
|
|
this.lastOptions = Object.assign({}, options);
|
|
|
|
// Fresh connkey for this stream
|
|
this.connKey = this.genConnKey();
|
|
this.streamCmdParms.connkey = this.connKey;
|
|
|
|
// Build zms URL
|
|
var src = this.url_to_zms +
|
|
'?source=event' +
|
|
'&mode=jpeg' +
|
|
'&event=' + eventId +
|
|
'&monitor=' + this.monitorId +
|
|
'&scale=' + this.scale +
|
|
'&rate=' + this.rate +
|
|
'&maxfps=' + (options.maxfps || 5) +
|
|
'&replay=' + (options.replay || 'none') +
|
|
'&connkey=' + this.connKey;
|
|
|
|
if (options.frame) {
|
|
src += '&frame=' + options.frame;
|
|
}
|
|
if (options.time) {
|
|
src += '&time=' + options.time;
|
|
}
|
|
|
|
// Auth
|
|
if (typeof auth_relay !== 'undefined' && auth_relay) {
|
|
src += '&' + auth_relay;
|
|
}
|
|
|
|
// Use a DOM <img> element for MJPEG reception. Browsers natively
|
|
// update a DOM <img> with each frame from a multipart/x-mixed-replace
|
|
// response, but a detached Image() object does not reliably trigger
|
|
// onload per frame. We position it off-screen and draw from it to
|
|
// the canvas on a requestAnimationFrame loop.
|
|
if (!this.img) {
|
|
this.img = document.createElement('img');
|
|
this.img.style.cssText = 'position:absolute;left:-9999px;top:-9999px;' +
|
|
'width:1px;height:1px;visibility:hidden;';
|
|
document.body.appendChild(this.img);
|
|
}
|
|
|
|
var self = this;
|
|
|
|
this.img.onerror = function() {
|
|
console.warn('EventStream: MJPEG stream error for event ' +
|
|
self.currentEventId + ' (monitor ' + self.monitorId + ')');
|
|
self.streamCmdTimer = clearInterval(self.streamCmdTimer);
|
|
if (self.rafId) {
|
|
cancelAnimationFrame(self.rafId);
|
|
self.rafId = null;
|
|
}
|
|
// Attempt recovery — zms likely died
|
|
self.recover();
|
|
};
|
|
|
|
// onload fires once when the first MJPEG frame arrives, confirming
|
|
// the zms process is running and the command socket is ready.
|
|
this.img.onload = function() {
|
|
// Successful frame — reset error counter
|
|
self.consecutiveErrors = 0;
|
|
self.recoveryDelay = 1000;
|
|
|
|
if (!self.streamCmdTimer) {
|
|
self.streamCmdQuery();
|
|
self.streamCmdTimer = setInterval(
|
|
self.streamCmdQuery.bind(self), self.statusInterval
|
|
);
|
|
}
|
|
};
|
|
|
|
// Start the rAF draw loop — draws whenever the browser has
|
|
// decoded a new MJPEG frame into the img element.
|
|
this.startDrawLoop();
|
|
|
|
// Setting src starts the MJPEG connection
|
|
this.img.src = src;
|
|
this.started = true;
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// stop() — Stop the current stream
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.stop = function() {
|
|
if (this.recoveryTimer) {
|
|
clearTimeout(this.recoveryTimer);
|
|
this.recoveryTimer = null;
|
|
}
|
|
|
|
if (!this.started) return;
|
|
|
|
this.streamCommand(CMD_QUIT);
|
|
this.streamCmdTimer = clearInterval(this.streamCmdTimer);
|
|
|
|
if (this.rafId) {
|
|
cancelAnimationFrame(this.rafId);
|
|
this.rafId = null;
|
|
}
|
|
|
|
if (this.img) {
|
|
this.img.onload = null;
|
|
this.img.onerror = null;
|
|
this.img.src = '';
|
|
if (this.img.parentNode) {
|
|
this.img.parentNode.removeChild(this.img);
|
|
}
|
|
this.img = null;
|
|
}
|
|
|
|
this.started = false;
|
|
this.paused = false;
|
|
this.connKey = null;
|
|
this.streamCmdParms.connkey = null;
|
|
this.consecutiveErrors = 0;
|
|
this.recoveryDelay = 1000;
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// recover() — Attempt to restart after zms death
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.recover = function() {
|
|
this.consecutiveErrors++;
|
|
|
|
if (this.consecutiveErrors > this.maxRecoveryAttempts) {
|
|
console.error('EventStream: max recovery attempts reached for monitor ' +
|
|
this.monitorId + ', giving up');
|
|
if (this.onError) this.onError('Stream recovery failed');
|
|
return;
|
|
}
|
|
|
|
console.warn('EventStream: recovery attempt ' + this.consecutiveErrors +
|
|
'/' + this.maxRecoveryAttempts + ' for monitor ' + this.monitorId);
|
|
|
|
var self = this;
|
|
var eventId = this.currentEventId;
|
|
var opts = Object.assign({}, this.lastOptions || {});
|
|
opts.rate = this.rate;
|
|
|
|
// Clean up old state without sending CMD_QUIT (zms is already dead)
|
|
this.streamCmdTimer = clearInterval(this.streamCmdTimer);
|
|
if (this.rafId) {
|
|
cancelAnimationFrame(this.rafId);
|
|
this.rafId = null;
|
|
}
|
|
if (this.img) {
|
|
this.img.onload = null;
|
|
this.img.onerror = null;
|
|
this.img.src = '';
|
|
if (this.img.parentNode) {
|
|
this.img.parentNode.removeChild(this.img);
|
|
}
|
|
this.img = null;
|
|
}
|
|
this.started = false;
|
|
this.connKey = null;
|
|
this.streamCmdParms.connkey = null;
|
|
|
|
// Delay before restarting — exponential backoff
|
|
this.recoveryTimer = setTimeout(function() {
|
|
self.recoveryTimer = null;
|
|
self.start(eventId, opts);
|
|
}, this.recoveryDelay);
|
|
|
|
this.recoveryDelay = Math.min(this.recoveryDelay * 2, 10000);
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// seek(offset) — Seek within the current event (seconds from start)
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.seek = function(offset) {
|
|
if (!this.started) return;
|
|
this.streamCommand({command: CMD_SEEK, offset: offset});
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// seekToTime(epochSecs) — Seek by wall-clock time
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.seekToTime = function(epochSecs) {
|
|
if (!this.started || !this.status) return;
|
|
// status.event gives us the current event ID; we need the event's
|
|
// start time to compute an offset. If the caller hasn't provided
|
|
// event metadata we fall back to duration-based estimation.
|
|
//
|
|
// For montagereview integration the caller will typically have the
|
|
// event start time available in the global `events` object.
|
|
var eventStartSecs = null;
|
|
if (typeof events !== 'undefined' && events[this.currentEventId]) {
|
|
eventStartSecs = events[this.currentEventId].StartTimeSecs;
|
|
}
|
|
if (eventStartSecs) {
|
|
var offset = epochSecs - eventStartSecs;
|
|
if (offset < 0) offset = 0;
|
|
this.seek(offset);
|
|
}
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// setRate(rate) — Change playback rate (100 = 1x realtime)
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.setRate = function(rate) {
|
|
this.rate = rate;
|
|
if (!this.started) return;
|
|
this.streamCommand({command: CMD_VARPLAY, rate: rate});
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// pause() / play()
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.pause = function() {
|
|
if (!this.started) return;
|
|
this.paused = true;
|
|
this.streamCommand(CMD_PAUSE);
|
|
};
|
|
|
|
this.play = function() {
|
|
if (!this.started) return;
|
|
this.paused = false;
|
|
this.streamCommand(CMD_PLAY);
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// setScale(scale) — Change the stream scale
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.setScale = function(scale) {
|
|
this.scale = scale;
|
|
if (!this.started) return;
|
|
this.streamCommand({command: CMD_SCALE, scale: scale});
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// switchEvent(eventId, options) — Switch to a different event
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.switchEvent = function(eventId, options) {
|
|
if (this.recoveryTimer) {
|
|
clearTimeout(this.recoveryTimer);
|
|
this.recoveryTimer = null;
|
|
}
|
|
|
|
if (this.started) {
|
|
// Tell current zms to exit
|
|
this.streamCommand(CMD_QUIT);
|
|
this.streamCmdTimer = clearInterval(this.streamCmdTimer);
|
|
if (this.rafId) {
|
|
cancelAnimationFrame(this.rafId);
|
|
this.rafId = null;
|
|
}
|
|
if (this.img) {
|
|
this.img.onload = null;
|
|
this.img.onerror = null;
|
|
this.img.src = '';
|
|
if (this.img.parentNode) {
|
|
this.img.parentNode.removeChild(this.img);
|
|
}
|
|
this.img = null;
|
|
}
|
|
this.started = false;
|
|
this.connKey = null;
|
|
this.streamCmdParms.connkey = null;
|
|
}
|
|
|
|
// Reset recovery state for fresh event
|
|
this.consecutiveErrors = 0;
|
|
this.recoveryDelay = 1000;
|
|
|
|
// Brief delay to let the old zms process clean up, then start fresh
|
|
var self = this;
|
|
setTimeout(function() {
|
|
self.start(eventId, options);
|
|
}, 200);
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// streamCommand(command) — Send a command to zms via AJAX
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.streamCommand = function(command) {
|
|
if (!this.started) {
|
|
return;
|
|
}
|
|
var params = Object.assign({}, this.streamCmdParms);
|
|
if (typeof command === 'object') {
|
|
for (var key in command) {
|
|
if (command.hasOwnProperty(key)) params[key] = command[key];
|
|
}
|
|
} else {
|
|
params.command = command;
|
|
}
|
|
this.streamCmdReq(params);
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// streamCmdReq(params) — Low-level AJAX to the command socket
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.streamCmdReq = function(params) {
|
|
var self = this;
|
|
this.ajaxQueue = jQuery.ajaxQueue({
|
|
url: this.url + (auth_relay ? '?' + auth_relay : ''),
|
|
xhrFields: {withCredentials: true},
|
|
data: params,
|
|
dataType: 'json'
|
|
})
|
|
.done(function(respObj) {
|
|
self.getStreamCmdResponse(respObj);
|
|
})
|
|
.fail(function(jqXHR, textStatus) {
|
|
if (textStatus === 'abort') return;
|
|
console.warn('EventStream: AJAX failed for monitor ' +
|
|
self.monitorId + ': ' + textStatus);
|
|
// AJAX failure likely means zms has died (socket gone)
|
|
self.recover();
|
|
});
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// streamCmdQuery() — Periodic CMD_QUERY for status updates
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.streamCmdQuery = function() {
|
|
if (this.started) {
|
|
var params = Object.assign({}, this.streamCmdParms);
|
|
params.command = CMD_QUERY;
|
|
this.streamCmdReq(params);
|
|
}
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// getStreamCmdResponse(respObj) — Handle CMD_QUERY / command responses
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.getStreamCmdResponse = function(respObj) {
|
|
if (!respObj) return;
|
|
|
|
if (respObj.result === 'Error' || respObj.result === 'Err') {
|
|
console.warn('EventStream: command error for monitor ' +
|
|
this.monitorId);
|
|
// Error response means stream.php couldn't talk to zms — recover
|
|
this.recover();
|
|
return;
|
|
}
|
|
|
|
// Successful response — reset error counter
|
|
this.consecutiveErrors = 0;
|
|
this.recoveryDelay = 1000;
|
|
|
|
if (!respObj.status) return;
|
|
|
|
this.status = respObj.status;
|
|
|
|
// Update auth hash if the server sent a fresh one
|
|
if (this.status.auth) {
|
|
if (typeof auth_hash !== 'undefined' && this.status.auth !== auth_hash) {
|
|
auth_hash = this.status.auth;
|
|
}
|
|
if (typeof auth_relay !== 'undefined' && this.status.auth_relay) {
|
|
auth_relay = this.status.auth_relay;
|
|
}
|
|
}
|
|
|
|
// Track paused state from server
|
|
if (this.status.paused !== undefined) {
|
|
this.paused = !!this.status.paused;
|
|
}
|
|
|
|
// Notify consumer
|
|
if (this.onStatus) {
|
|
this.onStatus(this.status);
|
|
}
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// startDrawLoop() — rAF loop that copies the MJPEG img to the canvas
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.startDrawLoop = function() {
|
|
var self = this;
|
|
function loop() {
|
|
if (!self.started) return;
|
|
self.drawFrame();
|
|
self.rafId = requestAnimationFrame(loop);
|
|
}
|
|
this.rafId = requestAnimationFrame(loop);
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// drawFrame() — Draw the current MJPEG frame to the canvas
|
|
// -------------------------------------------------------------------------
|
|
|
|
this.drawFrame = function() {
|
|
if (!this.canvas || !this.img) return;
|
|
// Only draw if the img has decoded at least one frame
|
|
if (!this.img.naturalWidth) return;
|
|
var ctx = this.canvas.getContext('2d');
|
|
ctx.drawImage(this.img, 0, 0, this.canvas.width, this.canvas.height);
|
|
if (this.onFrameDrawn) this.onFrameDrawn(this.canvas);
|
|
};
|
|
}
|