mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-11-05 12:12:58 -05:00
ZM\Event::canView() and canEdit() asked the event's Monitor, and an Event with no MonitorId (built from an empty or failed lookup) gets an empty Monitor, whose canView() falls back to the user's global Monitors permission. So an Event with no Id passed both checks for any user with Events access, whatever monitors they were denied. A caller that built an Event from a lookup that could come back empty and trusted canView() without checking Id() first failed open; the API's events/view did. Return false from both when the Event has no Id. Every caller in the web UI, ajax and API either checks Id() first or builds the Event from a database row, so none relied on the empty Event passing; callers that did not check (the frame view, export and download lists) now refuse a missing event id instead of working on an empty one. Test: tests/php/test_event_can_view_no_id.php drives the real ZM\Event with a stub Monitor (8 of 13 checks fail before this change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 054d4f0fa648e72a9d8fb494e86deef44a957977)
915 lines
33 KiB
PHP
915 lines
33 KiB
PHP
<?php
|
|
namespace ZM;
|
|
require_once('Storage.php');
|
|
require_once('functions.php');
|
|
require_once('Object.php');
|
|
require_once('Event_Tag.php');
|
|
require_once('Tag.php');
|
|
|
|
class Event extends ZM_Object {
|
|
protected static $setters = array('Storage', 'SecondaryStorage');
|
|
protected static $table = 'Events';
|
|
|
|
protected $Tags;
|
|
protected $Event_Tags;
|
|
|
|
protected $defaults = array(
|
|
'Id' => null,
|
|
'Name' => '',
|
|
'MonitorId' => null,
|
|
'StorageId' => null,
|
|
'SecondaryStorageId' => null,
|
|
'Cause' => '',
|
|
'StartDateTime' => null,
|
|
'EndDateTime' => null,
|
|
'Width' => null,
|
|
'Height' => null,
|
|
'Length' => null,
|
|
'Frames' => null,
|
|
'AlarmFrames' => null,
|
|
'DefaultVideo' => '',
|
|
'SaveJPEGs' => 0,
|
|
'TotScore' => 0,
|
|
'AvgScore' => 0,
|
|
'MaxScore' => 0,
|
|
'Archived' => 0,
|
|
'Videoed' => 0,
|
|
'Uploaded' => 0,
|
|
'Emailed' => 0,
|
|
'Messaged' => 0,
|
|
'Executed' => 0,
|
|
'Notes' => '',
|
|
'StateId' => 0,
|
|
'Orientation' => 0,
|
|
'DiskSpace' => null,
|
|
'Scheme' => 0,
|
|
'Locked' => 0,
|
|
);
|
|
public static function find( $parameters = array(), $options = array() ) {
|
|
return ZM_Object::_find(self::class, $parameters, $options);
|
|
}
|
|
|
|
public static function find_one( $parameters = array(), $options = array() ) {
|
|
return ZM_Object::_find_one(self::class, $parameters, $options);
|
|
}
|
|
|
|
public static function clear_cache() {
|
|
return ZM_Object::_clear_cache(self::class);
|
|
}
|
|
public function remove_from_cache() {
|
|
return ZM_Object::_remove_from_cache(self::class, $this);
|
|
}
|
|
|
|
public function Storage( $new = null ) {
|
|
if ( $new ) {
|
|
$this->{'Storage'} = $new;
|
|
}
|
|
if ( ! ( property_exists($this, 'Storage') and $this->{'Storage'} ) ) {
|
|
if ( isset($this->{'StorageId'}) and $this->{'StorageId'} )
|
|
$this->{'Storage'} = Storage::find_one(array('Id'=>$this->{'StorageId'}));
|
|
if ( ! ( property_exists($this, 'Storage') and $this->{'Storage'} ) ) {
|
|
$this->{'Storage'} = new Storage(NULL);
|
|
$this->{'Storage'}->Scheme($this->Scheme());
|
|
}
|
|
}
|
|
return $this->{'Storage'};
|
|
}
|
|
|
|
public function SecondaryStorage( $new = null ) {
|
|
if ( $new ) {
|
|
$this->{'SecondaryStorage'} = $new;
|
|
}
|
|
if ( ! ( property_exists($this, 'SecondaryStorage') and $this->{'SecondaryStorage'} ) ) {
|
|
if ( isset($this->{'SecondaryStorageId'}) and $this->{'SecondaryStorageId'} )
|
|
$this->{'SecondaryStorage'} = Storage::find_one(array('Id'=>$this->{'SecondaryStorageId'}));
|
|
if ( ! ( property_exists($this, 'SecondaryStorage') and $this->{'SecondaryStorage'} ) )
|
|
$this->{'SecondaryStorage'} = new Storage(NULL);
|
|
}
|
|
return $this->{'SecondaryStorage'};
|
|
}
|
|
|
|
private function GetFileDuration( $file ) {
|
|
$duration = 0;
|
|
if ( $file && file_exists($file) && defined('ZM_PATH_FFMPEG') && ZM_PATH_FFMPEG ) {
|
|
$ffmpeg = ZM_PATH_FFMPEG;
|
|
$ffprobe = preg_replace('/ffmpeg(\.exe)?$/i', 'ffprobe$1', $ffmpeg);
|
|
|
|
if ( $ffprobe && is_executable($ffprobe) ) {
|
|
$command = escapeshellarg($ffprobe)
|
|
. ' -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '
|
|
. escapeshellarg($file) . ' 2>&1';
|
|
$output = shell_exec($command);
|
|
if ( is_string($output) ) {
|
|
$output = trim($output);
|
|
if ( $output !== '' && is_numeric($output) ) {
|
|
$duration = (float)$output;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $duration;
|
|
}
|
|
|
|
public function Length(){
|
|
$duration = 0;
|
|
if ( !isset($this->{'Length'}) || (float)$this->{'Length'} <= 0 ) {
|
|
$files = glob($this->Path().'{/incomplete.*,/'.$this->{'Id'}.'-video.*}', GLOB_NOSORT | GLOB_BRACE);
|
|
if (count($files) > 0) {
|
|
$duration = $this->GetFileDuration($files[0]);
|
|
} else {
|
|
//TODO: IgorA100 Something needs to be done, but what exactly?
|
|
//$duration = $this->EndDateTimeSecs() - $this->StartDateTimeSecs();
|
|
}
|
|
} else {
|
|
$duration = $this->{'Length'};
|
|
}
|
|
return $duration;
|
|
}
|
|
|
|
public function Frames(){
|
|
if(! isset($this->{'Frames'})){
|
|
//TOOD: Do something when no Frames found
|
|
}
|
|
return $this->{'Frames'};
|
|
}
|
|
|
|
public function Monitor() {
|
|
if ( isset($this->{'MonitorId'}) ) {
|
|
$Monitor = Monitor::find_one(array('Id'=>$this->{'MonitorId'}));
|
|
if ( $Monitor )
|
|
return $Monitor;
|
|
}
|
|
return new Monitor();
|
|
}
|
|
|
|
public function Time() {
|
|
if ( ! isset($this->{'Time'}) ) {
|
|
$this->{'Time'} = strtotime($this->{'StartDateTime'});
|
|
}
|
|
return $this->{'Time'};
|
|
}
|
|
|
|
public function StartDateTimeSecs() {
|
|
return strtotime($this->{'StartDateTime'});
|
|
}
|
|
|
|
public function EndDateTimeSecs() {
|
|
return strtotime($this->{'EndDateTime'});
|
|
}
|
|
|
|
public function Duration() {
|
|
return $this->Length();
|
|
}
|
|
|
|
// DefaultVideo is user editable (API); only ever treat it as a filename
|
|
// inside the event directory.
|
|
public function DefaultVideo() {
|
|
return basename($this->{'DefaultVideo'} ?? '');
|
|
}
|
|
|
|
public function Path() {
|
|
$Storage = $this->Storage();
|
|
if ( $Storage->Path() and $this->Relative_Path() ) {
|
|
return $Storage->Path().'/'.$this->Relative_Path();
|
|
} else {
|
|
Error('Event Path not complete. Storage: '.$Storage->Path().' relative: '.$this->Relative_Path());
|
|
return '';
|
|
}
|
|
}
|
|
|
|
public function Relative_Path() {
|
|
$event_path = '';
|
|
|
|
if ( $this->{'Scheme'} == 'Deep' ) {
|
|
$event_path = $this->{'MonitorId'}.'/'.date('y/m/d/H/i/s', $this->Time());
|
|
} else if ( $this->{'Scheme'} == 'Medium' ) {
|
|
$event_path = $this->{'MonitorId'}.'/'.date('Y-m-d', $this->Time()).'/'.$this->{'Id'};
|
|
} else {
|
|
$event_path = $this->{'MonitorId'}.'/'.$this->{'Id'};
|
|
}
|
|
|
|
return $event_path;
|
|
} // end function Relative_Path()
|
|
|
|
public function Link_Path() {
|
|
if ( $this->{'Scheme'} == 'Deep' ) {
|
|
return $this->{'MonitorId'}.'/'.date('y/m/d/.', $this->Time()).$this->{'Id'};
|
|
}
|
|
Error('Calling Link_Path when not using deep storage');
|
|
return '';
|
|
}
|
|
|
|
public function delete() {
|
|
if ( ! $this->{'Id'} ) {
|
|
Error('Event delete on event with empty Id');
|
|
return;
|
|
}
|
|
if ( $this->{'Archived'} ) {
|
|
Error('Cannot delete an Archived event.');
|
|
return;
|
|
}
|
|
if ( ZM_OPT_FAST_DELETE ) {
|
|
dbQuery('DELETE FROM Events WHERE Id = ?', array($this->{'Id'}));
|
|
return;
|
|
}
|
|
|
|
$dbConn = zmDbConn();
|
|
$dbConn->beginTransaction();
|
|
try {
|
|
$this->lock();
|
|
dbQuery('DELETE FROM Stats WHERE EventId = ?', array($this->{'Id'}));
|
|
dbQuery('DELETE FROM Frames WHERE EventId = ?', array($this->{'Id'}));
|
|
if ( $this->{'Scheme'} == 'Deep' ) {
|
|
|
|
# Assumption: All events have a start time
|
|
$start_date = date_parse($this->{'StartDateTime'});
|
|
if ( ! $start_date ) {
|
|
throw new Exception('Unable to parse start date time for event ' . $this->{'Id'} . ' not deleting files.');
|
|
}
|
|
$start_date['year'] = $start_date['year'] % 100;
|
|
|
|
# So this is because ZM creates a link under the day pointing to the time that the event happened.
|
|
$link_path = $this->Link_Path();
|
|
if ( ! $link_path ) {
|
|
throw new Exception('Unable to determine link path for event '.$this->{'Id'}.' not deleting files.');
|
|
}
|
|
|
|
$Storage = $this->Storage();
|
|
$eventlink_path = $Storage->Path().'/'.$link_path;
|
|
|
|
if ( $id_files = glob($eventlink_path) ) {
|
|
if ( ! $eventPath = readlink($id_files[0]) ) {
|
|
throw new Exception("Unable to read link at $id_files[0]");
|
|
}
|
|
# I know we are using arrays here, but really there can only ever be 1 in the array
|
|
$eventPath = preg_replace('/\.'.$this->{'Id'}.'$/', $eventPath, $id_files[0]);
|
|
deletePath($eventPath);
|
|
deletePath($id_files[0]);
|
|
$pathParts = explode('/', $eventPath);
|
|
for ( $i = count($pathParts)-1; $i >= 2; $i-- ) {
|
|
$deletePath = join('/', array_slice($pathParts, 0, $i));
|
|
if ( !glob($deletePath.'/*') ) {
|
|
deletePath($deletePath);
|
|
}
|
|
}
|
|
} else {
|
|
Warning("Found no event files under $eventlink_path");
|
|
} # end if found files
|
|
} else {
|
|
$eventPath = $this->Path();
|
|
if ( ! $eventPath ) {
|
|
throw new Exception('No event Path in Event delete. Not deleting');
|
|
}
|
|
deletePath($eventPath);
|
|
if ( $this->SecondaryStorageId() ) {
|
|
$Storage = $this->SecondaryStorage();
|
|
if ( $Storage->Id() ) {
|
|
$eventPath = $Storage->Path().'/'.$this->Relative_Path();
|
|
if ( ! $eventPath ) {
|
|
Error('No event Path in Event delete. Not deleting');
|
|
} else {
|
|
deletePath($eventPath);
|
|
}
|
|
} # end if Storage
|
|
} # end if has Secondary Storage
|
|
} # USE_DEEP_STORAGE OR NOT
|
|
dbQuery('DELETE FROM Events WHERE Id = ?', array($this->{'Id'}));
|
|
$dbConn->commit();
|
|
} catch (PDOException $e) {
|
|
$dbConn->rollback();
|
|
} catch (Exception $e) {
|
|
Error($e->getMessage());
|
|
$dbConn->rollback();
|
|
}
|
|
} # end Event->delete
|
|
|
|
public function Server() {
|
|
if ( $this->Storage()->ServerId() ) {
|
|
# The Event may have been moved to Storage on another server,
|
|
# So prefer viewing the Event from the Server that is actually
|
|
# storing the video
|
|
return $this->Storage()->Server();
|
|
} else if ( $this->Monitor()->ServerId() ) {
|
|
# Assume that the server that recorded it has it
|
|
return $this->Monitor()->Server();
|
|
}
|
|
# A default Server will result in the use of ZM_DIR_EVENTS
|
|
return new Server();
|
|
}
|
|
|
|
public function getStreamSrc( $args=array(), $querySep='&' ) {
|
|
$Server = $this->Server();
|
|
|
|
# If we are in a multi-port setup, then use the multiport, else by
|
|
# passing null Server->Url will use the Port set in the Server setting
|
|
if ($args['mode'] == 'mp4') { #Downloading a video file. It is possible to reconsider the condition later.
|
|
#If the port is different from 80, the browser will start watching the video instead of downloading.
|
|
$port = null;
|
|
} else {
|
|
$port = ZM_MIN_STREAMING_PORT ?
|
|
ZM_MIN_STREAMING_PORT+$this->{'MonitorId'} :
|
|
null;
|
|
}
|
|
$streamSrc = $Server->Url($port);
|
|
|
|
if ( $this->{'DefaultVideo'} and $args['mode'] != 'jpeg' ) {
|
|
$streamSrc .= $Server->PathToIndex();
|
|
$args['eid'] = $this->{'Id'};
|
|
$args['view'] = (strtolower($args['mode']) == 'mp4hls') ? 'view_hls' : 'view_video';
|
|
} else {
|
|
$streamSrc .= $Server->PathToZMS();
|
|
|
|
$args['source'] = 'event';
|
|
$args['event'] = $this->{'Id'};
|
|
if ( ( (!isset($args['mode'])) or ( $args['mode'] != 'single' ) ) && !empty($GLOBALS['connkey']) ) {
|
|
$args['connkey'] = $GLOBALS['connkey'];
|
|
}
|
|
if ( ZM_RAND_STREAM ) {
|
|
$args['rand'] = time();
|
|
}
|
|
}
|
|
|
|
if ( ZM_OPT_USE_AUTH ) {
|
|
if ( ZM_AUTH_RELAY == 'hashed' ) {
|
|
$args['auth'] = generateAuthHash(ZM_AUTH_HASH_IPS);
|
|
// Include username so zms can filter by indexed Username column
|
|
// instead of iterating all users to validate the auth hash
|
|
if (!empty($_SESSION['username'])) $args['user'] = $_SESSION['username'];
|
|
} else if ( ZM_AUTH_RELAY == 'plain' ) {
|
|
$args['user'] = $_SESSION['username'];
|
|
$args['pass'] = $_SESSION['password'];
|
|
} else if ( ZM_AUTH_RELAY == 'none' ) {
|
|
$args['user'] = $_SESSION['username'];
|
|
}
|
|
}
|
|
|
|
$streamSrc .= '?'.http_build_query($args, '', $querySep);
|
|
|
|
return $streamSrc;
|
|
} // end function getStreamSrc
|
|
|
|
# The new='' is to so that if we pass null, we reset the value of DiskSpace.
|
|
# '' is not a valid DiskSpace so that tells us that nothing was passed whereas null (unknown) is.
|
|
function DiskSpace( $new='' ) {
|
|
if ( is_null($new) or ( $new != '' ) ) {
|
|
$this->{'DiskSpace'} = $new;
|
|
}
|
|
if ( (!property_exists($this, 'DiskSpace')) or (null === $this->{'DiskSpace'}) ) {
|
|
$this->{'DiskSpace'} = folder_size($this->Path());
|
|
if ($this->{'EndDateTime'} and $this->{'DiskSpace'}) {
|
|
# Finished events shouldn't grow in size much so we can commit it to the db.
|
|
#dbQuery('UPDATE Events SET DiskSpace=? WHERE Id=?', array($this->{'DiskSpace'}, $this->{'Id'}));
|
|
}
|
|
}
|
|
return $this->{'DiskSpace'};
|
|
}
|
|
|
|
function createListThumbnail( $overwrite=false ) {
|
|
# The idea here is that we don't really want to use the analysis jpeg as the thumbnail.
|
|
# The snapshot image will be generated during capturing
|
|
if ( file_exists($this->Path().'/snapshot.jpg') ) {
|
|
Debug("snapshot exists");
|
|
$frame = null;
|
|
} else {
|
|
# Load the frame with the highest score to use as a thumbnail
|
|
if ( !($frame = dbFetchOne( 'SELECT * FROM Frames WHERE EventId=? AND Score=? ORDER BY FrameId LIMIT 1', NULL, array( $this->{'Id'}, $this->{'MaxScore'} ) )) ) {
|
|
Error("Unable to find a Frame matching max score " . $this->{'MaxScore'} . ' for event ' . $this->{'Id'} );
|
|
// FIXME: What if somehow the db frame was lost or score was changed? Should probably try another search for any frame.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$imageData = $this->getImageSrc($frame, $scale, false, $overwrite);
|
|
if ( ! $imageData ) {
|
|
return false;
|
|
}
|
|
$thumbData = $frame;
|
|
$thumbData['Path'] = $imageData['thumbPath'];
|
|
$thumbData['Width'] = $this->ThumbnailWidth();
|
|
$thumbData['Height'] = $this->ThumbnailHeight();
|
|
$thumbData['url'] = '?view=image&eid='.$this->Id().'&fid='.$imageData['FrameId'].'&width='.$thumbData['Width'].'&height='.$thumbData['Height'];
|
|
|
|
return $thumbData;
|
|
} // end function createListThumbnail
|
|
|
|
function ThumbnailWidth( ) {
|
|
if ( ! ( property_exists($this, 'ThumbnailWidth') ) ) {
|
|
if ( ZM_WEB_LIST_THUMB_WIDTH ) {
|
|
$this->{'ThumbnailWidth'} = ZM_WEB_LIST_THUMB_WIDTH;
|
|
// Derive the height directly from the aspect ratio. Using an integer
|
|
// SCALE_BASE scale truncated to 0/1 for high-resolution monitors (e.g.
|
|
// 2688 wide -> scale 1), producing a squashed, wrong-aspect thumbnail
|
|
// (and a distorted hover-overlay). refs #3443
|
|
$this->{'ThumbnailHeight'} = (int)round($this->{'Height'} * ZM_WEB_LIST_THUMB_WIDTH / $this->{'Width'});
|
|
} elseif ( ZM_WEB_LIST_THUMB_HEIGHT ) {
|
|
$this->{'ThumbnailHeight'} = ZM_WEB_LIST_THUMB_HEIGHT;
|
|
$this->{'ThumbnailWidth'} = (int)round($this->{'Width'} * ZM_WEB_LIST_THUMB_HEIGHT / $this->{'Height'});
|
|
} else {
|
|
Fatal( "No thumbnail width or height specified, please check in Options->Web" );
|
|
}
|
|
}
|
|
return $this->{'ThumbnailWidth'};
|
|
} // end function ThumbnailWidth
|
|
|
|
function ThumbnailHeight( ) {
|
|
if ( ! ( property_exists($this, 'ThumbnailHeight') ) ) {
|
|
if ( ZM_WEB_LIST_THUMB_WIDTH ) {
|
|
$this->{'ThumbnailWidth'} = ZM_WEB_LIST_THUMB_WIDTH;
|
|
// Derive the height directly from the aspect ratio. Using an integer
|
|
// SCALE_BASE scale truncated to 0/1 for high-resolution monitors (e.g.
|
|
// 2688 wide -> scale 1), producing a squashed, wrong-aspect thumbnail
|
|
// (and a distorted hover-overlay). refs #3443
|
|
$this->{'ThumbnailHeight'} = (int)round($this->{'Height'} * ZM_WEB_LIST_THUMB_WIDTH / $this->{'Width'});
|
|
} elseif ( ZM_WEB_LIST_THUMB_HEIGHT ) {
|
|
$this->{'ThumbnailHeight'} = ZM_WEB_LIST_THUMB_HEIGHT;
|
|
$this->{'ThumbnailWidth'} = (int)round($this->{'Width'} * ZM_WEB_LIST_THUMB_HEIGHT / $this->{'Height'});
|
|
} else {
|
|
Fatal( "No thumbnail width or height specified, please check in Options->Web" );
|
|
}
|
|
}
|
|
return $this->{'ThumbnailHeight'};
|
|
} // end function ThumbnailHeight
|
|
|
|
function getThumbnailSrc( $args=array(), $querySep='&' ) {
|
|
# The thumbnail is theoretically the image with the most motion.
|
|
# We always store at least 1 image when capturing
|
|
|
|
$streamSrc = '';
|
|
$Server = $this->Server();
|
|
$streamSrc .= $Server->UrlToIndex(
|
|
ZM_MIN_STREAMING_PORT ?
|
|
ZM_MIN_STREAMING_PORT+$this->{'MonitorId'} :
|
|
null);
|
|
|
|
$args['eid'] = $this->{'Id'};
|
|
if (file_exists($this->Path().'/objdetect.jpg')) {
|
|
$args['fid'] = 'objdetect';
|
|
} else {
|
|
$args['fid'] = 'snapshot';
|
|
}
|
|
$args['view'] = 'image';
|
|
$args['width'] = $this->ThumbnailWidth();
|
|
$args['height'] = $this->ThumbnailHeight();
|
|
|
|
if ( ZM_OPT_USE_AUTH ) {
|
|
if ( ZM_AUTH_RELAY == 'hashed' ) {
|
|
$args['auth'] = generateAuthHash(ZM_AUTH_HASH_IPS);
|
|
// Include username so zms can filter by indexed Username column
|
|
// instead of iterating all users to validate the auth hash
|
|
if (!empty($_SESSION['username'])) $args['user'] = $_SESSION['username'];
|
|
} else if ( ZM_AUTH_RELAY == 'plain' ) {
|
|
$args['user'] = $_SESSION['username'];
|
|
$args['pass'] = $_SESSION['password'];
|
|
} else if ( ZM_AUTH_RELAY == 'none' ) {
|
|
$args['user'] = $_SESSION['username'];
|
|
}
|
|
}
|
|
|
|
return $streamSrc.'?'.http_build_query($args,'', $querySep);
|
|
} // end function getThumbnailSrc
|
|
|
|
// frame is an array representing the db row for a frame.
|
|
function getImageSrc($frame, $scale=SCALE_BASE, $captureOnly=false, $overwrite=false) {
|
|
$Storage = $this->Storage();
|
|
$Event = $this;
|
|
$eventPath = $Event->Path();
|
|
|
|
if ( $frame and !is_array($frame) ) {
|
|
Debug("Assuming that $frame is an Id");
|
|
$f = Frame::find_one(['Id'=>$frame]);
|
|
if ($f) {
|
|
$frame = (array)$f;
|
|
} else {
|
|
$frame = $this->find_virtual_frame($frame);
|
|
if (!$frame)
|
|
$frame = array('FrameId'=>$frame, 'Type'=>'', 'Delta'=>0);
|
|
}
|
|
}
|
|
|
|
if ( ( !$frame ) and file_exists($eventPath.'/snapshot.jpg') ) {
|
|
# No frame specified, so look for a snapshot to use
|
|
$captureImage = 'snapshot.jpg';
|
|
Debug('Frame not specified, using snapshot');
|
|
$frame = array('FrameId'=>'snapshot', 'Type'=>'', 'Delta'=>0);
|
|
} else {
|
|
$captureImage = sprintf('%0'.ZM_EVENT_IMAGE_DIGITS.'d-analyze.jpg', $frame['FrameId']);
|
|
if ( ! file_exists( $eventPath.'/'.$captureImage ) ) {
|
|
$captureImage = sprintf('%0'.ZM_EVENT_IMAGE_DIGITS.'d-capture.jpg', $frame['FrameId']);
|
|
if ( !file_exists($eventPath.'/'.$captureImage) ) {
|
|
# Generate the frame JPG
|
|
if ( $Event->DefaultVideo() ) {
|
|
$videoPath = $eventPath.'/'.$Event->DefaultVideo();
|
|
|
|
if ( !file_exists($videoPath) ) {
|
|
Error('Event claims to have a video file, but it does not seem to exist at '.$videoPath);
|
|
return '';
|
|
}
|
|
|
|
if ( !is_executable(ZM_PATH_FFMPEG) ) {
|
|
Error('ZM_PATH_FFMPEG is not a valid executable: '.ZM_PATH_FFMPEG);
|
|
return '';
|
|
}
|
|
$command = ZM_PATH_FFMPEG.' -ss '.escapeshellarg($frame['Delta']).' -i '.escapeshellarg($videoPath).' -frames:v 1 '.escapeshellarg($eventPath.'/'.$captureImage).' 2>&1';
|
|
Debug('Running '.$command);
|
|
$output = array();
|
|
$retval = 0;
|
|
exec($command, $output, $retval);
|
|
Debug("Retval: $retval, output: " . implode("\n", $output));
|
|
} else {
|
|
Error('Can\'t create frame images from video because there is no video file for event '.$Event->Id().' at ' .$Event->Path());
|
|
}
|
|
} // end if capture file exists
|
|
} // end if analyze file exists
|
|
} // end if frame or snapshot
|
|
|
|
$capturePath = $eventPath.'/'.$captureImage;
|
|
if ( !file_exists($capturePath) ) {
|
|
Error('Capture file does not exist at '.$capturePath);
|
|
}
|
|
|
|
$analysisImage = sprintf('%0'.ZM_EVENT_IMAGE_DIGITS.'d-analyse.jpg', $frame['FrameId']);
|
|
$analysisPath = $eventPath.'/'.$analysisImage;
|
|
|
|
$alarmFrame = $frame['Type'] == 'Alarm';
|
|
|
|
$hasAnalysisImage = $alarmFrame && file_exists($analysisPath) && filesize($analysisPath);
|
|
$isAnalysisImage = $hasAnalysisImage && !$captureOnly;
|
|
|
|
if ( !ZM_WEB_SCALE_THUMBS || !$scale || ($scale >= SCALE_BASE) || !function_exists('imagecreatefromjpeg') ) {
|
|
$imagePath = $thumbPath = $isAnalysisImage ? $analysisPath : $capturePath;
|
|
$imageFile = $imagePath;
|
|
$thumbFile = $thumbPath;
|
|
} else {
|
|
if ( version_compare(phpversion(), '4.3.10', '>=') )
|
|
$fraction = sprintf('%.3F', $scale/SCALE_BASE);
|
|
else
|
|
$fraction = sprintf('%.3f', $scale/SCALE_BASE);
|
|
$scale = (int)round($scale);
|
|
|
|
$thumbCapturePath = preg_replace('/\.jpg$/', "-$scale.jpg", $capturePath);
|
|
$thumbAnalysisPath = preg_replace('/\.jpg$/', "-$scale.jpg", $analysisPath);
|
|
|
|
if ( $isAnalysisImage ) {
|
|
$imagePath = $analysisPath;
|
|
$thumbPath = $thumbAnalysisPath;
|
|
} else {
|
|
$imagePath = $capturePath;
|
|
$thumbPath = $thumbCapturePath;
|
|
}
|
|
|
|
$thumbFile = $thumbPath;
|
|
if ( $overwrite || ! file_exists($thumbFile) || ! filesize($thumbFile) ) {
|
|
// Get new dimensions
|
|
list($imageWidth, $imageHeight) = getimagesize($imagePath);
|
|
$thumbWidth = $imageWidth * $fraction;
|
|
$thumbHeight = $imageHeight * $fraction;
|
|
|
|
// Resample
|
|
$thumbImage = imagecreatetruecolor($thumbWidth, $thumbHeight);
|
|
$image = imagecreatefromjpeg($imagePath);
|
|
imagecopyresampled($thumbImage, $image, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $imageWidth, $imageHeight);
|
|
|
|
if ( !imagejpeg($thumbImage, $thumbPath) )
|
|
Error("Can't create thumbnail '$thumbPath'");
|
|
}
|
|
} # Create thumbnails
|
|
|
|
$imageData = array(
|
|
'eventPath' => $eventPath,
|
|
'imagePath' => $imagePath,
|
|
'thumbPath' => $thumbPath,
|
|
'imageFile' => $imagePath,
|
|
'thumbFile' => $thumbFile,
|
|
'imageClass' => $alarmFrame?'alarm':'normal',
|
|
'isAnalysisImage' => $isAnalysisImage,
|
|
'hasAnalysisImage' => $hasAnalysisImage,
|
|
'FrameId' => $frame['FrameId'],
|
|
);
|
|
|
|
return $imageData;
|
|
} # getImageSrc
|
|
|
|
public function link_to($text=null) {
|
|
if ( !$text )
|
|
$text = $this->{'Id'};
|
|
return '<a href="?view=event&eid='. $this->{'Id'}.'">'.$text.'</a>';
|
|
}
|
|
|
|
public function file_exists() {
|
|
if ( file_exists( $this->Path().'/'.$this->DefaultVideo() ) ) {
|
|
return true;
|
|
}
|
|
if ( !defined('ZM_SERVER_ID') ) {
|
|
return false;
|
|
}
|
|
$Storage= $this->Storage();
|
|
$Server = $this->Server();
|
|
if ( $Server->Id() != ZM_SERVER_ID ) {
|
|
|
|
$url = $Server->UrlToApi() . '/events/'.$this->{'Id'}.'.json';
|
|
if ( ZM_OPT_USE_AUTH ) {
|
|
if ( ZM_AUTH_RELAY == 'hashed' ) {
|
|
$url .= '?auth='.generateAuthHash( ZM_AUTH_HASH_IPS );
|
|
} else if ( ZM_AUTH_RELAY == 'plain' ) {
|
|
$url .= '?user='.$_SESSION['username'];
|
|
$url .= '?pass='.$_SESSION['password'];
|
|
} else {
|
|
Error('Multi-Server requires AUTH_RELAY be either HASH or PLAIN');
|
|
return;
|
|
}
|
|
}
|
|
Debug("sending command to $url");
|
|
// use key 'http' even if you send the request to https://...
|
|
$options = array(
|
|
'http' => array(
|
|
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
|
|
'method' => 'GET',
|
|
'content' => ''
|
|
)
|
|
);
|
|
$context = stream_context_create($options);
|
|
try {
|
|
$result = file_get_contents($url, false, $context);
|
|
if ($result === FALSE) { /* Handle error */
|
|
Error("Error restarting zmc using $url");
|
|
}
|
|
$event_data = json_decode($result,true);
|
|
Debug(print_r($event_data['event']['Event'],1));
|
|
return $event_data['event']['Event']['fileExists'];
|
|
} catch ( Exception $e ) {
|
|
Error("Except $e thrown trying to get event data");
|
|
}
|
|
} # end if not local
|
|
return false;
|
|
} # end public function file_exists()
|
|
|
|
public function file_size() {
|
|
if ( file_exists($this->Path().'/'.$this->DefaultVideo()) ) {
|
|
return filesize($this->Path().'/'.$this->DefaultVideo());
|
|
}
|
|
if ( !defined('ZM_SERVER_ID') ) {
|
|
return false;
|
|
}
|
|
$Storage= $this->Storage();
|
|
$Server = $this->Server();
|
|
if ( $Server->Id() != ZM_SERVER_ID ) {
|
|
|
|
$url = $Server->UrlToApi().'/events/'.$this->{'Id'}.'.json';
|
|
if ( ZM_OPT_USE_AUTH ) {
|
|
if ( ZM_AUTH_RELAY == 'hashed' ) {
|
|
$url .= '?auth='.generateAuthHash(ZM_AUTH_HASH_IPS);
|
|
} elseif ( ZM_AUTH_RELAY == 'plain' ) {
|
|
$url .= '?user='.$_SESSION['username'];
|
|
$url .= '?pass='.$_SESSION['password'];
|
|
} else {
|
|
Error('Multi-Server requires AUTH_RELAY be either HASH or PLAIN');
|
|
return;
|
|
}
|
|
}
|
|
Debug("sending command to $url");
|
|
// use key 'http' even if you send the request to https://...
|
|
$options = array(
|
|
'http' => array(
|
|
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
|
|
'method' => 'GET',
|
|
'content' => ''
|
|
)
|
|
);
|
|
$context = stream_context_create($options);
|
|
try {
|
|
$result = file_get_contents($url, false, $context);
|
|
if ( $result === FALSE ) { /* Handle error */
|
|
Error("Error restarting zmc using $url");
|
|
}
|
|
$event_data = json_decode($result,true);
|
|
Debug(print_r($event_data['event']['Event'], 1));
|
|
return $event_data['event']['Event']['fileSize'];
|
|
} catch ( Exception $e ) {
|
|
Error("Except $e thrown trying to get event data");
|
|
}
|
|
} # end if not local
|
|
return 0;
|
|
} # end public function file_size()
|
|
|
|
public function can_delete() {
|
|
if ( $this->Archived() ) {
|
|
return false;
|
|
}
|
|
if ( !$this->EndDateTime() ) {
|
|
return false;
|
|
}
|
|
if ( !canEdit('Events') ) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function cant_delete_reason() {
|
|
if ( $this->Archived() ) {
|
|
return 'You cannot delete an archived event. Unarchive it first.';
|
|
} else if ( ! $this->EndDateTime() ) {
|
|
return 'You cannot delete an event while it is being recorded. Wait for it to finish.';
|
|
} else if ( ! canEdit('Events') ) {
|
|
return 'You do not have rights to edit Events.';
|
|
}
|
|
return 'Unknown reason';
|
|
}
|
|
|
|
function canView($u=null) {
|
|
global $user;
|
|
if (!$u) $u=$user;
|
|
if (!$u) {
|
|
# auth turned on and not logged in
|
|
return false;
|
|
}
|
|
# An Event with no Id (an empty or failed lookup) has no monitor, and an
|
|
# empty Monitor passes canView() on the global Monitors permission. Refuse
|
|
# it, so a caller that skips its own Id() check does not fail open.
|
|
if (!$this->Id()) return false;
|
|
if (!$this->Monitor()->canView($u)) return false;
|
|
|
|
# canView() counts the user's role as well as their own field. refs #5189
|
|
if ($u->canView('Events')) {
|
|
return true;
|
|
}
|
|
if ($u->canView('Snapshots')) {
|
|
# If the event is contained in a snapshot, then we can still view it.
|
|
if (dbFetchOne('SELECT * FROM Snapshots_Events WHERE EventId=?', false, array($this->Id())))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function canEdit($u=null) {
|
|
global $user;
|
|
if (!$u) $u=$user;
|
|
if (!$u) {
|
|
# auth turned on and not logged in
|
|
return false;
|
|
}
|
|
if (!$this->Id()) return false;
|
|
if (!$this->Monitor()->canView($u)) return false;
|
|
if (!$u->canEdit('Events')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function createVideo($format, $rate, $scale, $transform, $overwrite=false) {
|
|
$command = ZM_PATH_BIN.'/zmvideo.pl -e '.escapeshellarg($this->{'Id'})
|
|
.' -f '.escapeshellarg(preg_replace('/[^\w]/', '', $format))
|
|
.' -r '.escapeshellarg(sprintf('%.2F', ($rate/RATE_BASE)));
|
|
if (preg_match('/^\d+x\d+$/', $scale)) {
|
|
$command .= ' -S '.escapeshellarg($scale);
|
|
} else {
|
|
$command .= ' -s '.escapeshellarg(sprintf('%.2F', ($scale/SCALE_BASE)));
|
|
}
|
|
if ($transform != '') {
|
|
$transform = preg_replace('/[^\w=]/', '', $transform);
|
|
$command .= ' -t '.escapeshellarg($transform);
|
|
}
|
|
if ($overwrite)
|
|
$command .= ' -o';
|
|
$result = exec($command, $output, $status);
|
|
Debug("generating Video $command: result($result outptu:(".implode("\n", $output )." status($status");
|
|
return $status ? '' : rtrim($result);
|
|
}
|
|
|
|
public function find_virtual_frame($fid) {
|
|
$frame = null;
|
|
|
|
$previousBulkFrame = dbFetchOne(
|
|
'SELECT * FROM Frames WHERE EventId=? AND FrameId < ? ORDER BY FrameID DESC LIMIT 1',
|
|
NULL, array($this->Id(), $fid)
|
|
);
|
|
$nextBulkFrame = dbFetchOne(
|
|
'SELECT * FROM Frames WHERE EventId=? AND FrameId > ? ORDER BY FrameID ASC LIMIT 1',
|
|
NULL, array($this->Id(), $fid)
|
|
);
|
|
if ($previousBulkFrame and $nextBulkFrame) {
|
|
$frame = new Frame($previousBulkFrame);
|
|
$frame->FrameId($fid);
|
|
|
|
$percentage = ($frame->FrameId() - $previousBulkFrame['FrameId']) / ($nextBulkFrame['FrameId'] - $previousBulkFrame['FrameId']);
|
|
$frame->Delta($previousBulkFrame['Delta'] + floor( 100* ( $nextBulkFrame['Delta'] - $previousBulkFrame['Delta'] ) * $percentage )/100);
|
|
Debug('Got virtual frame from Bulk Frames previous delta: ' . $previousBulkFrame['Delta'] . ' + nextdelta:' . $nextBulkFrame['Delta'] . ' - ' . $previousBulkFrame['Delta'] . ' * ' . $percentage );
|
|
} else if ($previousBulkFrame) {
|
|
//If no next Frame we have to pull data from the Event itself
|
|
$frame = new Frame($previousBulkFrame);
|
|
$frame->FrameId($_REQUEST['fid']);
|
|
|
|
$percentage = ($frame->FrameId()/$this->Frames());
|
|
|
|
$frame->Delta(floor($this->Length() * $percentage));
|
|
}
|
|
return $frame;
|
|
}
|
|
|
|
public function Event_Tags() {
|
|
if (!isset($this->Event_Tags)) {
|
|
$this->Event_Tags = $this->Id() ? Event_Tag::find(['EventId'=>$this->Id()]) : [];
|
|
}
|
|
return $this->Event_Tags;
|
|
}
|
|
|
|
public function Tags() {
|
|
if (!isset($this->Tags)) {
|
|
$this->Tags = array_map(function($t){return $t->Tag();}, $this->Event_Tags());
|
|
} else {
|
|
Debug("Have Tags");
|
|
}
|
|
return $this->Tags;
|
|
}
|
|
|
|
public function GenerateVideo($rate=0, $fps=0, $scale=0, $size=0, $overwrite=false, $format='mp4', $transforms='') {
|
|
$event_path = $this->Path();
|
|
// Name is user editable; keep the output file inside the event directory.
|
|
$video_name = preg_replace('/^\./', '_', preg_replace('/[^-A-Za-z0-9_.]/', '_', $this->Name()));
|
|
|
|
$file_parts = [$video_name];
|
|
if ( $rate ) {
|
|
$file_rate = str_replace('.', '_', $rate);
|
|
$file_rate = str_replace('_00', '', $file_rate);
|
|
$file_rate = preg_replace('/(_\d+)0+$/', '$1', $file_rate);
|
|
$file_parts[] = 'r'.$file_rate;
|
|
} else if ( $fps ) {
|
|
$file_fps = str_replace('.', '_', $fps);
|
|
$file_fps = str_replace('_00', '', $file_fps);
|
|
$file_fps = preg_replace('/(_\d+)0+$/', '$1', $file_rate);
|
|
$file_parts[] = 'R'.$file_fps;
|
|
}
|
|
|
|
if ( $scale ) {
|
|
$file_scale = str_replace('.', '_', $scale);
|
|
$file_scale = str_replace('/_00/', '', $file_scale);
|
|
$file_scale = preg_replace('/(_\d+)0+$/', '$1', $file_scale);
|
|
$file_parts[] = 's'.$file_scale;
|
|
} else if ( $size ) {
|
|
$file_size = 'S'.$size;
|
|
$file_parts[] = $file_size;
|
|
}
|
|
array_merge($file_parts, explode(',', $transforms));
|
|
$video_file = implode('-', $file_parts).'.'.$format;
|
|
if ( $overwrite || ! file_exists($video_file) ) {
|
|
Info("Creating video file $video_file for event ".$this->Id());
|
|
|
|
$frame_rate = sprintf('%.2f', $this->Frames()/$this->Length());
|
|
if ($rate) {
|
|
if ( $rate != 1.0 ) {
|
|
$frame_rate *= $rate;
|
|
}
|
|
} else if ( $fps ) {
|
|
$frame_rate = $fps;
|
|
}
|
|
|
|
$width = $this->Width();
|
|
$height = $this->Height();
|
|
$video_size = " {$width}x{$height}";
|
|
|
|
if ( $scale ) {
|
|
if ( $scale != 1.0 ) {
|
|
$width = int($width*$scale);
|
|
$height = int($height*$scale);
|
|
$video_size = " {$width}x{$height}";
|
|
}
|
|
} else if ( $size ) {
|
|
$video_size = $size;
|
|
}
|
|
// Every argument is quoted on its own so no event field can add
|
|
// arguments or commands. The configured option strings hold several
|
|
// options each, so they are split on whitespace; shell quoting inside
|
|
// them is not honoured.
|
|
$command = array_merge(
|
|
[ZM_PATH_FFMPEG, '-y', '-r', $frame_rate],
|
|
preg_split('/\s+/', ZM_FFMPEG_INPUT_OPTIONS, -1, PREG_SPLIT_NO_EMPTY),
|
|
['-i', $event_path.'/'.( $this->DefaultVideo() ? $this->DefaultVideo() : '%0'.ZM_EVENT_IMAGE_DIGITS .'d-capture.jpg' )]
|
|
);
|
|
foreach (explode(',', $transforms) as $t) {
|
|
if ($t !== '') array_push($command, '-vf', $t);
|
|
}
|
|
array_push($command, '-s', trim($video_size));
|
|
$command = array_merge($command,
|
|
preg_split('/\s+/', ZM_FFMPEG_OUTPUT_OPTIONS, -1, PREG_SPLIT_NO_EMPTY),
|
|
[$event_path.'/'.$video_file]
|
|
);
|
|
Debug(implode(' ', $command));
|
|
$log = $event_path.'/ffmpeg.log';
|
|
$process = proc_open(implode(' ', array_map('escapeshellarg', $command)), [0=>['file', '/dev/null', 'r'], 1=>['file', $log, 'w'], 2=>['file', $log, 'a']], $pipes);
|
|
if (!is_resource($process) or proc_close($process) != 0) {
|
|
Error("Unable to generate video, check $log for details");
|
|
return;
|
|
}
|
|
|
|
Info("Finished $video_file");
|
|
return $video_file;
|
|
} else {
|
|
Info("Video file $video_file already exists for event {$this['Id']}");
|
|
return $video_file;
|
|
}
|
|
return;
|
|
} # end sub GenerateVideo
|
|
|
|
} # end class
|
|
?>
|