Difference between revisions of "How to use a video file as a Source"
(Created page with "There was [https://forums.zoneminder.com/viewtopic.php?f=43&t=31176 a question on the forums] a while ago asking whether it was possible to test zones against stored events, w...") |
(New code to allow the use of Event IDs; better Testing and Usage instructions; general tidying up) |
||
Line 1: | Line 1: | ||
There was [https://forums.zoneminder.com/viewtopic.php?f=43&t=31176 a question on the forums] a while ago asking whether it was possible to test zones against stored events, which interested me, and I've cobbled together a | There was [https://forums.zoneminder.com/viewtopic.php?f=43&t=31176 a question on the forums] a while ago asking whether it was possible to test zones against stored events, which interested me, and I've cobbled together a method of doing it with PHP and FFMpeg. You can use it to stream a ZM event or a video file. | ||
=== How it works === | |||
FFmpeg does all the real work, reading a video file at real-time speed and outputting it to the client. The rest of the code is a wrapper around the FFmpeg command, validating parameters and locating the file to be streamed. | |||
=== Code === | === Code === | ||
Create a file named <kbd>replay-test.php</kbd> in your server's web root (not the ZM root) | Create a file named <kbd>replay-test.php</kbd> in your server's web root (not the ZM root) and paste this into it. You shouldn't have to change anything, just copy/paste/save. | ||
<div style="height: 300px; overflow: scroll;"> | |||
<pre> | <pre> | ||
<?PHP | <?PHP | ||
// | // Usage / ZM Source syntax | ||
$infile = "/ | // To stream a ZM event: http://server/replay-test.php?e=eventID | ||
// To stream a video file: http://server/replay-test.php?f=/full/path/to/file.mp4 | |||
// Source file | |||
// Used only if you don't set an eventID below and don't | |||
// pass a filename or event ID in the URL query string. | |||
// Must be the /full/path/to/file.mp4 | |||
$infile = ""; | |||
// Event ID | |||
// Used only if you don't pass an eventID or filename in the URL query string. | |||
// Use 0 for none | |||
$eventID = 0; | |||
// Database info | |||
// We need this when using event IDs. | |||
// We'll try to get to get it from the | |||
// ZM conf files so you'll only need to | |||
// set it if that fails and you get an error. | |||
// If you set one item then you must set all. | |||
$db = array( "ZM_DB_HOST" => "", | |||
"ZM_DB_NAME" => "", | |||
"ZM_DB_USER" => "", | |||
"ZM_DB_PASS" => "" ); | |||
// Whether to loop the video | // Whether to loop the video | ||
// Use -1 for infinite, | // Use -1 for infinite, | ||
// 0 for single-shot, | // 0 for single-shot, | ||
// 1 for 1 loop ( | // 1 for 1 loop (2 plays), etc. | ||
// Anything other than -1 will throw | // Anything other than -1 will throw warnings in the ZM | ||
// | // log when the stream ends and the zmc_ process restarts. | ||
$loopcount = -1; | $loopcount = -1; | ||
Line 22: | Line 50: | ||
$ffmpeg = "/usr/bin/ffmpeg"; | $ffmpeg = "/usr/bin/ffmpeg"; | ||
// | // Path to grep | ||
$grep = "/bin/grep"; | |||
// | // No configuration variables below here | ||
// URL parameters | |||
if( !empty($_GET['f']) ) { | if( !empty($_GET['f']) ) { | ||
$infile = $_GET['f']; | |||
$eventID = ""; | |||
} | |||
if( !empty($_GET['e']) ) { | |||
$eventID = $_GET['e']; | |||
} | |||
// Got anything to do? | |||
if( empty($infile) && empty($eventID) ) { | |||
header("HTTP/1.1 400 Bad Request (no filename or event ID)"); | |||
echo "No filename or event ID"; | |||
exit; | |||
} | |||
// Get a filepath if we've got an eventID | |||
if( !empty($eventID) ) { | |||
$infile = getFilenameByEventID($eventID); | |||
} | } | ||
// Can we read the file? | // Can we read the file? | ||
if( !is_readable($infile) ) { | if( empty($infile) || !is_readable($infile) ) { | ||
header("HTTP/1.1 500 Internal Server Error (can't read file)"); | |||
echo "Can't read file '".$infile."')"; | |||
exit; | |||
} | |||
// ffmpeg good to go? | |||
if( !is_executable($ffmpeg) ) { | |||
header("HTTP/1.1 500 Internal server error (can't execute ffmpeg)"); | |||
echo "Can't execute ffmpeg - please set the path"; | |||
exit; | |||
} | } | ||
// Don't | // Don't let max_execution_time kill us | ||
set_time_limit(0); | set_time_limit(0); | ||
// No output buffering or compression | |||
// Try to cover all the bases... | |||
@apache_setenv('no-gzip', 1); | |||
@ini_set('output_buffering', 'Off'); | |||
@ini_set('output_handler', ''); | |||
@ini_set('zlib.output_compression', 'Off'); | |||
@ob_end_flush(); | |||
@flush; | |||
// Tell the client what we're sending | // Tell the client what we're sending | ||
header( "Content-Type: video/mp4" ); | header( "Content-Type: video/mp4" ); | ||
// | // And invoke FFmpeg to send it... | ||
passthru( $ffmpeg." -stream_loop ".$loopcount." -re -i '".$infile."' -vcodec copy -an -vsync 0 -f mp4 -movflags frag_keyframe+empty_moov - 2>/dev/null"); | passthru( $ffmpeg." -stream_loop ".$loopcount." -re -fflags +genpts -i '".$infile."' -vcodec copy -an -sn -vsync 0 -map_metadata -1 -f mp4 -movflags frag_keyframe+empty_moov - 2>/dev/null"); | ||
// All done | |||
exit; | |||
== | function getFilenameByEventID( $eventID ) { | ||
// We'll need these | |||
// Don't like globals but don't want to pass anything other than eventID | |||
$db = $GLOBALS['db']; | |||
$grep = $GLOBALS['grep']; | |||
You can test the stream by opening it in a browser or media player such as MPC-HC or VLC | // Do we need to get database details from the .conf? | ||
if( empty($db['ZM_DB_HOST']) ) { | |||
if( !is_executable($grep) ) { | |||
header("HTTP/1.1 500 Internal server error (can't execute grep)"); | |||
echo "Can't execute grep - please set the path"; | |||
exit; | |||
} | |||
exec("$grep -hE \"^\s*ZM_DB_\" /etc/zm/zm.conf /etc/zm/conf.d/*.conf", $db); | |||
foreach($db as $key => $value) { | |||
unset($db[$key]); | |||
$newkey = trim(preg_replace("/^\s*(ZM_DB_[^=]*)=\s*(.*)\s*$/", "$1", $value )); | |||
$newvalue = trim(preg_replace("/^\s*(ZM_DB_[^=]*)=\s*(.*)\s*$/", "$2", $value )); | |||
if($newkey) { | |||
$db[$newkey] = $newvalue; | |||
} | |||
} | |||
unset($value); | |||
} | |||
// Got 'em all? | |||
if( empty($db['ZM_DB_HOST']) || empty($db['ZM_DB_NAME']) || empty($db['ZM_DB_USER']) || empty($db['ZM_DB_PASS']) ) { | |||
header("HTTP/1.1 500 Internal server error (database info incomplete)"); | |||
echo "Database info incomplete - Please set it manually"; | |||
exit; | |||
} | |||
// Connect to the database | |||
mysqli_report(MYSQLI_REPORT_OFF); | |||
if( !$dbHandle = @mysqli_connect($db['ZM_DB_HOST'], $db['ZM_DB_USER'], $db['ZM_DB_PASS'], $db['ZM_DB_NAME']) ) { | |||
header("HTTP/1.1 500 Internal server error (can't connect to database)"); | |||
echo "Can't connect to database: ".mysqli_error($dbhandle); | |||
exit; | |||
} | |||
// Query it for the file path | |||
$query = "SELECT CONCAT(s.`path`, '/', e.`MonitorId`, '/', LEFT(e.`StartDateTime`, 10), '/', e.`Id`, '/', e.`defaultVideo`) | |||
FROM `Events` e | |||
INNER JOIN `Storage` s | |||
ON s.`Id` = e.`StorageID` | |||
WHERE e.`id` = '".mysqli_real_escape_string($dbHandle, $eventID)."'"; | |||
if( !$result = @mysqli_query($dbHandle, $query) ) { | |||
header("HTTP/1.1 500 Internal server error (database query failed)"); | |||
echo "Database query failed: ".mysqli_error($dbHandle); | |||
exit; | |||
} | |||
// Got a result? | |||
if( (!$fullpath = @mysqli_fetch_row($result)) || empty($fullpath[0]) ) { | |||
header("HTTP/1.1 500 Internal server error (empty result set or path)"); | |||
echo "Mysql returned empty result or path (bad event ID?)"; | |||
exit; | |||
} | |||
mysqli_close($dbHandle); | |||
return $fullpath[0]; | |||
} | |||
?></pre> | |||
</div> | |||
=== Testing and Usage === | |||
You can test the stream by opening it in a browser or a media player such as MPC-HC or VLC. | |||
If you entered a filename or an event ID in the code then you can simply visit <kbd><nowiki>http://yourserver/replay-test.php</nowiki></kbd> | |||
If you didn't enter a filename or event ID in the code then you'll need to add one to the url. | |||
If you're using an event ID then the URL format is <kbd><nowiki>http://yourserver/replay-test.php?e=eventID</nowiki></kbd> (substituting the actual event ID for 'eventID') | |||
If you want to use a file then the format is <kbd><nowiki>http://yourserver/replay-test.php?f=/full/path/to/file.mp4</nowiki></kbd> | |||
If the video doesn't play and you're using a media player then try a browser - there'll probably be something useful in the output. | |||
If you're using a browser and it just shows a black screen then the video might be H.265, which most of them don't support. | |||
=== Setting up the monitor in ZM === | === Setting up the monitor in ZM === | ||
To use this in ZM, set up a new monitor with a Source Type of FFMpeg, a Source Path URL as described in the Testing and Usage section above, and TCP as the Method. | |||
That's it - You should be good to go and ZM should start capturing :) | That's it - You should be good to go and ZM should start capturing :) | ||
=== Notes === | === Notes === | ||
Line 65: | Line 209: | ||
The best results will probably be obtained with videos that used Camera Passthrough as the writer because then ZM will see the same thing as it did the first time - other methods will be lossy and although the differences may be imperceptible to us, machines will spot them. If you must use an encoder then try to use a low CRF, such as 19 or even 17 to avoid too much degradation. | The best results will probably be obtained with videos that used Camera Passthrough as the writer because then ZM will see the same thing as it did the first time - other methods will be lossy and although the differences may be imperceptible to us, machines will spot them. If you must use an encoder then try to use a low CRF, such as 19 or even 17 to avoid too much degradation. | ||
If you change the source video path or event ID in the PHP or if you overwrite the video file then you'll have stop and restart the monitor in ZM. | |||
If you change the source video path in the PHP or if you overwrite the video file then you'll have stop and restart the monitor in ZM. | |||
I find that the loop tends to stick at the end before restarting, but that could very well be the way my video is encoded and it may not happen to you. | I find that the loop tends to stick at the end before restarting, but that could very well be the way my video is encoded and it may not happen to you. | ||
I suggest using sources with | I suggest using sources with a few fairly static frames at their start end end to avoid spurious detection at the wraparound where the scene might change significantly (in ZM terms). | ||
Comments, | Comments, questions, and criticisms are welcome in [https://forums.zoneminder.com/viewtopic.php?f=11&t=31355 the forum thread] | ||
I think that's all for now - Good luck! | I think that's all for now - Good luck! |
Latest revision as of 11:05, 3 December 2021
There was a question on the forums a while ago asking whether it was possible to test zones against stored events, which interested me, and I've cobbled together a method of doing it with PHP and FFMpeg. You can use it to stream a ZM event or a video file.
How it works
FFmpeg does all the real work, reading a video file at real-time speed and outputting it to the client. The rest of the code is a wrapper around the FFmpeg command, validating parameters and locating the file to be streamed.
Code
Create a file named replay-test.php in your server's web root (not the ZM root) and paste this into it. You shouldn't have to change anything, just copy/paste/save.
<?PHP // Usage / ZM Source syntax // To stream a ZM event: http://server/replay-test.php?e=eventID // To stream a video file: http://server/replay-test.php?f=/full/path/to/file.mp4 // Source file // Used only if you don't set an eventID below and don't // pass a filename or event ID in the URL query string. // Must be the /full/path/to/file.mp4 $infile = ""; // Event ID // Used only if you don't pass an eventID or filename in the URL query string. // Use 0 for none $eventID = 0; // Database info // We need this when using event IDs. // We'll try to get to get it from the // ZM conf files so you'll only need to // set it if that fails and you get an error. // If you set one item then you must set all. $db = array( "ZM_DB_HOST" => "", "ZM_DB_NAME" => "", "ZM_DB_USER" => "", "ZM_DB_PASS" => "" ); // Whether to loop the video // Use -1 for infinite, // 0 for single-shot, // 1 for 1 loop (2 plays), etc. // Anything other than -1 will throw warnings in the ZM // log when the stream ends and the zmc_ process restarts. $loopcount = -1; // Path to ffpmeg - try 'which ffmpeg' at the CLI to find yours $ffmpeg = "/usr/bin/ffmpeg"; // Path to grep $grep = "/bin/grep"; // No configuration variables below here // URL parameters if( !empty($_GET['f']) ) { $infile = $_GET['f']; $eventID = ""; } if( !empty($_GET['e']) ) { $eventID = $_GET['e']; } // Got anything to do? if( empty($infile) && empty($eventID) ) { header("HTTP/1.1 400 Bad Request (no filename or event ID)"); echo "No filename or event ID"; exit; } // Get a filepath if we've got an eventID if( !empty($eventID) ) { $infile = getFilenameByEventID($eventID); } // Can we read the file? if( empty($infile) || !is_readable($infile) ) { header("HTTP/1.1 500 Internal Server Error (can't read file)"); echo "Can't read file '".$infile."')"; exit; } // ffmpeg good to go? if( !is_executable($ffmpeg) ) { header("HTTP/1.1 500 Internal server error (can't execute ffmpeg)"); echo "Can't execute ffmpeg - please set the path"; exit; } // Don't let max_execution_time kill us set_time_limit(0); // No output buffering or compression // Try to cover all the bases... @apache_setenv('no-gzip', 1); @ini_set('output_buffering', 'Off'); @ini_set('output_handler', ''); @ini_set('zlib.output_compression', 'Off'); @ob_end_flush(); @flush; // Tell the client what we're sending header( "Content-Type: video/mp4" ); // And invoke FFmpeg to send it... passthru( $ffmpeg." -stream_loop ".$loopcount." -re -fflags +genpts -i '".$infile."' -vcodec copy -an -sn -vsync 0 -map_metadata -1 -f mp4 -movflags frag_keyframe+empty_moov - 2>/dev/null"); // All done exit; function getFilenameByEventID( $eventID ) { // We'll need these // Don't like globals but don't want to pass anything other than eventID $db = $GLOBALS['db']; $grep = $GLOBALS['grep']; // Do we need to get database details from the .conf? if( empty($db['ZM_DB_HOST']) ) { if( !is_executable($grep) ) { header("HTTP/1.1 500 Internal server error (can't execute grep)"); echo "Can't execute grep - please set the path"; exit; } exec("$grep -hE \"^\s*ZM_DB_\" /etc/zm/zm.conf /etc/zm/conf.d/*.conf", $db); foreach($db as $key => $value) { unset($db[$key]); $newkey = trim(preg_replace("/^\s*(ZM_DB_[^=]*)=\s*(.*)\s*$/", "$1", $value )); $newvalue = trim(preg_replace("/^\s*(ZM_DB_[^=]*)=\s*(.*)\s*$/", "$2", $value )); if($newkey) { $db[$newkey] = $newvalue; } } unset($value); } // Got 'em all? if( empty($db['ZM_DB_HOST']) || empty($db['ZM_DB_NAME']) || empty($db['ZM_DB_USER']) || empty($db['ZM_DB_PASS']) ) { header("HTTP/1.1 500 Internal server error (database info incomplete)"); echo "Database info incomplete - Please set it manually"; exit; } // Connect to the database mysqli_report(MYSQLI_REPORT_OFF); if( !$dbHandle = @mysqli_connect($db['ZM_DB_HOST'], $db['ZM_DB_USER'], $db['ZM_DB_PASS'], $db['ZM_DB_NAME']) ) { header("HTTP/1.1 500 Internal server error (can't connect to database)"); echo "Can't connect to database: ".mysqli_error($dbhandle); exit; } // Query it for the file path $query = "SELECT CONCAT(s.`path`, '/', e.`MonitorId`, '/', LEFT(e.`StartDateTime`, 10), '/', e.`Id`, '/', e.`defaultVideo`) FROM `Events` e INNER JOIN `Storage` s ON s.`Id` = e.`StorageID` WHERE e.`id` = '".mysqli_real_escape_string($dbHandle, $eventID)."'"; if( !$result = @mysqli_query($dbHandle, $query) ) { header("HTTP/1.1 500 Internal server error (database query failed)"); echo "Database query failed: ".mysqli_error($dbHandle); exit; } // Got a result? if( (!$fullpath = @mysqli_fetch_row($result)) || empty($fullpath[0]) ) { header("HTTP/1.1 500 Internal server error (empty result set or path)"); echo "Mysql returned empty result or path (bad event ID?)"; exit; } mysqli_close($dbHandle); return $fullpath[0]; } ?>
Testing and Usage
You can test the stream by opening it in a browser or a media player such as MPC-HC or VLC.
If you entered a filename or an event ID in the code then you can simply visit http://yourserver/replay-test.php
If you didn't enter a filename or event ID in the code then you'll need to add one to the url.
If you're using an event ID then the URL format is http://yourserver/replay-test.php?e=eventID (substituting the actual event ID for 'eventID')
If you want to use a file then the format is http://yourserver/replay-test.php?f=/full/path/to/file.mp4
If the video doesn't play and you're using a media player then try a browser - there'll probably be something useful in the output.
If you're using a browser and it just shows a black screen then the video might be H.265, which most of them don't support.
Setting up the monitor in ZM
To use this in ZM, set up a new monitor with a Source Type of FFMpeg, a Source Path URL as described in the Testing and Usage section above, and TCP as the Method.
That's it - You should be good to go and ZM should start capturing :)
Notes
The video is passed straight through rather than being transcoded so playback quality will be exactly as it was recorded in the file.
The best results will probably be obtained with videos that used Camera Passthrough as the writer because then ZM will see the same thing as it did the first time - other methods will be lossy and although the differences may be imperceptible to us, machines will spot them. If you must use an encoder then try to use a low CRF, such as 19 or even 17 to avoid too much degradation.
If you change the source video path or event ID in the PHP or if you overwrite the video file then you'll have stop and restart the monitor in ZM.
I find that the loop tends to stick at the end before restarting, but that could very well be the way my video is encoded and it may not happen to you.
I suggest using sources with a few fairly static frames at their start end end to avoid spurious detection at the wraparound where the scene might change significantly (in ZM terms).
Comments, questions, and criticisms are welcome in the forum thread
I think that's all for now - Good luck!