server 1.2:

- removed -r command line option and renamed it to -s. then
   added -l as what -s used to be.

 - added detect_uri option to camera parameters so stream
   snapshots can now run a secondary stream that differs
   from the recording stream. also added sync logic so the
   recording loop and detection loop will run synchronously
   while still running in seperate threads.

 - live_secs renamed to live_mins. the amount of live
   footage to buffer in buffer_path is now based on minutes
   instead of seconds.

 - added live_clip_secs parameter so the amount of seconds
   each hls video clip should have is now adjustable.

 - remove max_event_secs. events are now transfered to
   rec_path based on minute increments.

 - added sec_per_image to make the amount of seconds
   between stream snapshots pulled from detect_uri
   adjustable.

 - added img_ext so the image snapshot format from
   detect_uri and then ultimately to img_comp_cmd can now
   be configured instead of being hardcoded to bmp.

 - added gray_image as a boolean parameter so the snapshots
   from detect_uri can be pulled in grayscale format or
   remain in color if desired.

 - removed img_scale since this parameter was not doing
   anything.

overview:

   use of QFileSystemWatcher on the detection loop has
   been eliminated and thus eliminated use of functions
   such as listFacingFiles(), backwardFacingFiles() and
   forwardFacingFiles(). Instead, the detection and
   recording loops will now run synchronously and use
   predictable up-to-the-minute file naming scheme.

client v1.1:

 - the build script will now include all imageformat
   plugins from the QT lib directory, adding support for
   jpg, svg, ico and gif image formats.

 - switched to 2 number versioning.

 - this client app will now support the server's new
   up-to-the-minute directory structure for live and
   recorded footage.

 - the version number is now be displayed on the main
   interface.
This commit is contained in:
Zii 2025-10-11 08:05:51 -04:00
parent c0f167e5b1
commit e72feb40ee
17 changed files with 545 additions and 311 deletions

View File

@ -43,10 +43,10 @@ Usage: jmotion <argument>
-u : uninstall the entire server from your system, including the service. all
recorded footage and config files will remain.
-f : force an action without pausing for user confirmation.
-s : view the status of all camera instances.
-l : view the status of all camera instances.
-q : kill all camera instances.
-r : same as -d except it is non-blocking for starting all camera instances
via systemd. same as 'systemctl start jmotion'
-s : same as -d except it is non-blocking for starting all camera instances
via systemd. same as 'sudo systemctl start jmotion'
```
### Config File (server) ###
@ -65,6 +65,13 @@ recording_uri = rtsp://1.2.3.4:554/h264
# to record footage. it can be a url to an rtsp stream or a direct device
# path such as /dev/video0.
#
detect_uri = rtsp://1.2.3.4:554/h264cif
# this is the uri to the secondary stream of the IP camera that will be
# used to take image snapshots that will be used with img_comp_cmd to
# detect any events of intrest. it can be a url to an rtsp stream or a
# direct device path such as /dev/video0. If the camera doesn't have a
# secondary stream, you can just use the same as recording_uri.
#
buffer_path = /tmp/jmotion
# this is the work directory the app will use to store live footage and
# image frames. it's recommended to use a ram disk for this since there
@ -73,12 +80,17 @@ buffer_path = /tmp/jmotion
#
rec_path = /var/jmotion/footage
# this is video output directory that will be used to store any footage
# that contain any motion events.
# that contain any detected events.
#
live_secs = 160
# this is the maximum amount of seconds worth of live footage to keep in
# buffer_path before deleting the oldest 3 seconds worth of footage.
# note: each video clip in buffer_path is typically 3 seconds long.
live_mins = 60
# this is the maximum amount minutes worth of live video clips to record
# to the buffer_path before deleting the oldest video clips. values less
# than 1 is considered invalid and will cause the app to default to 60.
#
live_clip_secs = 5
# this is the amount of seconds each video clip recorded from recording_uri
# can be. values between 1-59 are valid. anything outside of that range will
# cause the app to default to 5.
#
cam_name = cam-1
# this is the optional camera name parameter to identify the camera. this
@ -89,27 +101,27 @@ max_events_bytes = 10G
# this is the maximum amount of disk space of video footage that can be
# recorded in the rec_path directory.
#
max_event_secs = 30
# this is the maximum amount of secs of video footage that can be recorded
# in a motion event.
#
img_comp_cmd = compare -metric FUZZ &prev& &next& /dev/null
# this is the command line template this application will use when calling
# the external image comparison application. the external application is
# expected to compare snapshots from the video stream to determine how
# different the images are from each other. it needs to output a numeric
# score with the higher values meaning very different while low values
# mean similar images. the snapshots pulled from the stream will be bitmap
# formatted so the app will be required to support this format. also avoid
# outputting any special chars, only numeric chars with a single '.' if
# outputting a decimal value. magick is the default if not defined in the
# config file. this parameter can also be set to 'null' as a means to turn
# off motion detection.
# mean similar images. magick is the default if not defined in the config
# file. this parameter can also be set to 'null' as a means to turn off
# motion/event detection.
#
sec_per_image = 4
# this is the amount of seconds between video stream snap shots to take
# from detect_uri that will then be passed on to img_comp_cmd via the &prev&
# and &next& parameters. values between 1-59 are valid. anything outside of
# that range will cause the app to default to 4.
#
img_comp_out = stderr
# this is the standard output stream the app defined in img_comp_cmd will
# use to output the comparison score. this can only be stderr or stdout,
# any other stream name is considered invalid.
# any other stream name is considered invalid. it will default to 'stderr'
# if the parameter is invalid.
#
vid_codec = copy
# this is the encoding codec to use when recording footage from the camera.
@ -129,6 +141,11 @@ stream_ext = .mkv
# from the camera in buffer_path. ffmpeg will also use this to determine
# what format container to use for the video clips.
#
img_ext = .jpg
# this is the file extension that will be used with detect_uri to take
# snapshots of the video stream. make sure the app defined in img_comp_cmd
# supports the image format defined here.
#
thumbnail_ext = .jpg
# this the image format that will be used when creating the thumbnails
# for the videos clips recorded to rec_path.
@ -140,19 +157,25 @@ rec_ext = .mkv
#
img_thresh = 8000
# this parameter defines the score threshold from img_comp_cmd that will
# be considered motion. any motion events will queue up max_event_secs
# worth of video clips to be written out to rec_path.
# be considered an event of any kind. any video clips found with events
# are queued up to be copied to rec_path.
#
gray_image = y
# this is a boolean 'y' or 'n' parameter that enables or disable grayscale
# color formatting for the images pulled from the camera stream via the
# detect_uri. recommend turning this off if the img_comp_cmd already does
# this internally.
#
post_secs = 60
# this is the amount of seconds to wait before running the command
# defined in post_cmd. the command will not run if motion was detected
# defined in post_cmd. the command will not run if an event was detected
# in the space before post_secs elapsed.
#
post_cmd = move_the_ptz_camera.py
# this an optional command to run with post_secs. one great use for this
# is to move a ptz camera to the next position of it's patrol pattern.
# note: the call to this command will be delayed if motion was detected.
# also, motion detection is paused while this command is running.
# note: the call to this command will be delayed if an event was detected.
# also, event detection is paused while this command is running.
#
rec_fps = 30
# this sets the recording frames per second for the footage recorded
@ -163,11 +186,6 @@ rec_scale = 1280:720
# uses width, height numeric strings seperated by a colon, eg W:H. this
# has no affect of using 'copy' as the vid_codec.
#
img_scale = 320:240
# this sets the pixel size of the thumbnails for recorded stored in
# rec_path. it uses width, height numeric strings seperated by a colon,
# eg W:H.
#
```
### Build/Install ###

View File

@ -108,10 +108,14 @@ def linux_build_app_dir(app_ver, app_name, app_target, qt_bin):
if not os.path.exists("app_dir/icons"):
os.makedirs("app_dir/icons")
if not os.path.exists("app_dir/imageformats"):
os.makedirs("app_dir/imageformats")
verbose_copy(qt_bin + "/../plugins/platforms", "app_dir/platforms")
verbose_copy(qt_bin + "/../plugins/xcbglintegrations", "app_dir/xcbglintegrations")
verbose_copy(qt_bin + "/../plugins/multimedia", "app_dir/multimedia")
verbose_copy(qt_bin + "/../plugins/platformthemes", "app_dir/platformthemes")
verbose_copy(qt_bin + "/../plugins/imageformats", "app_dir/imageformats")
verbose_copy("build/" + app_target, "app_dir/" + app_target)
verbose_copy("icons/main.svg", "app_dir/icons/scalable.svg")

View File

@ -121,7 +121,8 @@ def local_install(app_target, app_name):
verbose_copy("app_dir/xcbglintegrations", install_dir + "/xcbglintegrations")
verbose_copy("app_dir/multimedia", install_dir + "/multimedia")
verbose_copy("app_dir/platformthemes", install_dir + "/platformthemes")
verbose_copy("app_dir/imageformats", install_dir + "/imageformats")
verbose_create_symmlink(install_dir + "/" + app_target + ".sh", "/usr/bin/" + app_target)
subprocess.run(["chmod", "644", "/usr/share/icons/hicolor/scalable/apps/jmc.svg"])

View File

@ -66,7 +66,7 @@
#include <QRandomGenerator>
#include <QProcess>
#define APP_VERSION "1.0.0"
#define APP_VERSION "1.1"
#define APP_NAME "JustMotion-Client"
#define APP_TARGET "jmotion-client"
#define DEFAULT_PLAYER "vlc --no-audio --no-video-title-show --no-osd --repeat pls.m3u8"

View File

@ -43,6 +43,8 @@ MainWidget::MainWidget(shared_t *share, MainGui *parent) : QWidget(parent)
sshOpts = new ssh_opt();
homeLayout = new QVBoxLayout(this);
homeLayout->addWidget(new QLabel(tr("Version: ") + APP_VERSION, this));
genConnectWidget();
genSettingsWidget();
genSpacer();

View File

@ -55,7 +55,7 @@ void PlaylistItem::checkConfExists()
void PlaylistItem::loadImgFromFile(QString path)
{
if (path.isEmpty() || !QFileInfo::exists(path))
if (path.isEmpty() || !QFileInfo(path).isFile())
{
path = ":/img/no_mdeia.png";
}
@ -67,7 +67,12 @@ void PlaylistItem::loadImgFromFile(QString path)
void PlaylistItem::genImgAndText()
{
auto imgDir = basePath + QDir::separator() + "img" + QDir::separator();
auto imgDir = basePath + QDir::separator() + "img" + QDir::separator();
auto imgFolderList = lsDirsInDir(imgDir);
auto imgFolder = randPickItem(imgFolderList);
imgDir = imgDir + imgFolder + QDir::separator();
auto imgList = lsImgFiles(imgDir);
auto imgFile = randPickItem(imgList);

View File

@ -104,6 +104,8 @@ int Camera::start(const QString &conf)
connect(thr3, &QThread::finished, this, &Camera::objMinusOne);
connect(detLoop, &DetectLoop::starving, recLoop, &RecordLoop::restart);
connect(recLoop, &RecordLoop::started, detLoop, &DetectLoop::run);
connect(recLoop, &RecordLoop::finished, detLoop, &DetectLoop::terminate);
thr1->start();
thr2->start();

View File

@ -155,28 +155,31 @@ bool rdConf(const QString &filePath, shared_t *share)
else
{
share->recordUri.clear();
share->detectUri.clear();
share->postCmd.clear();
share->camName.clear();
share->buffPath.clear();
share->recPath.clear();
share->retCode = 0;
share->imgThresh = 8000;
share->postSecs = 60;
share->evMaxSecs = 30;
share->evtMaxBytes = 10000000000;
share->conf = filePath;
share->outputType = "stderr";
share->compCmd = "compare -metric FUZZ " + QString(PREV_IMG) + " " + QString(NEXT_IMG) + " /dev/null";
share->vidCodec = "copy";
share->audCodec = "copy";
share->streamExt = ".mkv";
share->recExt = ".mkv";
share->thumbExt = ".bmp";
share->recFps = 30;
share->liveSecs = 160;
share->recScale = "1280:720";
share->imgScale = "320:240";
share->retCode = 0;
share->imgThresh = 8000;
share->postSecs = 300;
share->evtMaxBytes = 10000000000;
share->conf = filePath;
share->outputType = "stderr";
share->compCmd = "compare -metric FUZZ " + QString(PREV_IMG) + " " + QString(NEXT_IMG) + " /dev/null";
share->vidCodec = "copy";
share->audCodec = "copy";
share->streamExt = ".ts";
share->recExt = ".ts";
share->thumbExt = ".jpg";
share->imgExt = ".jpg";
share->recFps = 30;
share->liveMins = 60;
share->liveClipSecs = 5;
share->secPerImg = 4;
share->grayImg = true;
share->recScale = "1280:720";
QString line;
@ -188,15 +191,16 @@ bool rdConf(const QString &filePath, shared_t *share)
{
rdLine("cam_name = ", line, &share->camName);
rdLine("recording_uri = ", line, &share->recordUri);
rdLine("detect_uri = ", line, &share->detectUri);
rdLine("buffer_path = ", line, &share->buffPath);
rdLine("rec_path = ", line, &share->recPath);
rdLine("max_event_secs = ", line, &share->evMaxSecs);
rdLine("post_secs = ", line, &share->postSecs);
rdLine("post_cmd = ", line, &share->postCmd);
rdLine("img_thresh = ", line, &share->imgThresh);
rdLine("max_events_bytes = ", line, &share->evtMaxBytes);
rdLine("img_comp_out = ", line, &share->outputType);
rdLine("img_comp_cmd = ", line, &share->compCmd);
rdLine("img_ext = ", line, &share->imgExt);
rdLine("vid_codec = ", line, &share->vidCodec);
rdLine("aud_codec = ", line, &share->audCodec);
rdLine("stream_ext = ", line, &share->streamExt);
@ -204,8 +208,10 @@ bool rdConf(const QString &filePath, shared_t *share)
rdLine("thumbnail_ext = ", line, &share->thumbExt);
rdLine("rec_fps = ", line, &share->recFps);
rdLine("rec_scale = ", line, &share->recScale);
rdLine("img_scale = ", line, &share->imgScale);
rdLine("live_secs = ", line, &share->liveSecs);
rdLine("live_mins = ", line, &share->liveMins);
rdLine("live_clip_secs = ", line, &share->liveClipSecs);
rdLine("sec_per_image = ", line, &share->secPerImg);
rdLine("gray_image = ", line, &share->grayImg);
}
} while(!line.isEmpty());
@ -218,9 +224,33 @@ bool rdConf(const QString &filePath, shared_t *share)
extCorrection(share->streamExt);
extCorrection(share->recExt);
extCorrection(share->thumbExt);
extCorrection(share->imgExt);
if ((share->liveClipSecs > 59) || (share->liveClipSecs <= 0))
{
qInfo() << "live_clip_secs = " << share->liveClipSecs << " is not valid. must be between 1-59. default set: " << 5;
share->liveClipSecs = 5;
}
if (share->liveMins <= 0)
{
qInfo() << "live_mins = " << share->liveMins << " is not valid. must be greater than 1. default set: " << 60;
share->liveMins = 60;
}
if ((share->secPerImg > 59) || (share->secPerImg <= 0))
{
qInfo() << "sec_per_image = " << share->secPerImg << " is not valid. must be between 1-59. default set: " << 10;
share->secPerImg = 4;
}
if (share->outputType != "stdout" && share->outputType != "stderr")
{
qInfo() << "img_comp_out = " << share->outputType << " is not valid. must be stdout or stderr only. default set: stderr";
share->outputType = "stderr";
}
@ -241,21 +271,47 @@ bool rdConf(const QString &filePath, shared_t *share)
{
share->recPath = QDir::cleanPath(share->recPath);
}
if (share->liveSecs < 10)
{
share->liveSecs = 10;
}
if ((share->liveSecs - 4) < share->evMaxSecs)
{
share->evMaxSecs = share->liveSecs - 4;
}
}
return share->retCode == 0;
}
QString getStatLineFromProc(QProcess *proc)
{
if (proc->state() == QProcess::Running)
{
return "OK ";
}
else if (proc->state() == QProcess::Starting)
{
return "STARTING";
}
else if (proc->error() == QProcess::FailedToStart)
{
return "FAILED ";
}
else if (proc->error() == QProcess::UnknownError)
{
return "STOPPED ";
}
else if (proc->error() == QProcess::Crashed)
{
return "CRASHED ";
}
else if (proc->error() == QProcess::Timedout)
{
return "TIMEOUT ";
}
else if (proc->error() == QProcess::WriteError)
{
return "WR_ERR ";
}
else
{
return "RD_ERR ";
}
}
void setupBuffDir(const QString &path, bool del)
{
if (del)

View File

@ -32,11 +32,11 @@
using namespace std;
#define APP_VERSION "1.1"
#define APP_VERSION "1.2"
#define APP_NAME "JustMotion"
#define APP_TARGET "jmotion"
#define DATETIME_FMT "yyyyMMddhhmmss"
#define STRFTIME_FMT "%Y%m%d%H%M%S"
#define DATETIME_FMT "yyyyMMddhhmm"
#define STRFTIME_FMT "%Y%m%d%H%M"
#define PREV_IMG "&prev&"
#define NEXT_IMG "&next&"
@ -46,39 +46,37 @@ enum CmdExeType
VID_LOOP
};
struct evt_t
{
QList<QString> vidList;
QList<QString> imgList;
};
struct shared_t
{
evt_t recList;
QString conf;
QString recordUri;
QString buffPath;
QString postCmd;
QString camName;
QString recPath;
QString outputType;
QString compCmd;
QString vidCodec;
QString audCodec;
QString streamExt;
QString recExt;
QString thumbExt;
QString recScale;
QString imgScale;
quint64 evtMaxBytes;
int liveSecs;
int recFps;
int evMaxSecs;
int postSecs;
int imgThresh;
int retCode;
QStringList recList;
QString conf;
QString recordUri;
QString detectUri;
QString buffPath;
QString postCmd;
QString camName;
QString recPath;
QString outputType;
QString compCmd;
QString vidCodec;
QString audCodec;
QString streamExt;
QString recExt;
QString imgExt;
QString thumbExt;
QString recScale;
quint64 evtMaxBytes;
bool grayImg;
int liveMins;
int liveClipSecs;
int recFps;
int secPerImg;
int postSecs;
int imgThresh;
int retCode;
};
QString getStatLineFromProc(QProcess *proc);
QString buildThreadCount(int count);
QStringList lsFilesInDir(const QString &path, const QString &ext = QString());
QStringList lsDirsInDir(const QString &path);
@ -95,5 +93,6 @@ void rdLine(const QString &param, const QString &line, int *value);
void rdLine(const QString &param, const QString &line, quint64 *value);
void rdLine(const QString &param, const QString &line, bool *value);
void extCorrection(QString &ext);
void addStrsToList(QStringList &strList, QStringList &add);
#endif // COMMON_H

View File

@ -12,11 +12,12 @@
#include "detect_loop.h"
DetectLoop::DetectLoop(shared_t *sharedRes, QThread *thr, QObject *parent) : QFileSystemWatcher(parent)
DetectLoop::DetectLoop(shared_t *sharedRes, QThread *thr, QObject *parent) : QProcess(parent)
{
evtOn = false;
pcTimer = 0;
shared = sharedRes;
pcTimer = 0;
starvCnt = 0;
shared = sharedRes;
rerun = false;
connect(thr, &QThread::started, this, &DetectLoop::init);
connect(thr, &QThread::finished, this, &DetectLoop::deleteLater);
@ -24,59 +25,99 @@ DetectLoop::DetectLoop(shared_t *sharedRes, QThread *thr, QObject *parent) : QFi
moveToThread(thr);
}
DetectLoop::~DetectLoop()
{
terminate();
stopTimers();
waitForFinished();
}
void DetectLoop::init()
{
pcTimer = new QTimer(this);
evtTimer = new QTimer(this);
pcTimer = new QTimer(this);
evtTimer = new QTimer(this);
detTimer = new QTimer(this);
mkdirTimer = new QTimer(this);
postProc = new QProcess(this);
setupBuffDir(shared->buffPath);
connect(pcTimer, &QTimer::timeout, this, &DetectLoop::pcBreak);
pcTimer->setInterval(shared->postSecs * 1000);
mkdirTimer->setInterval(60000);
detTimer->setInterval((shared->secPerImg * 2) * 1000);
evtTimer->setInterval(60000);
evtTimer->setSingleShot(true);
connect(this, &QFileSystemWatcher::directoryChanged, this, &DetectLoop::updated);
connect(pcTimer, &QTimer::timeout, this, &DetectLoop::pcBreak);
connect(detTimer, &QTimer::timeout, this, &DetectLoop::exec);
connect(mkdirTimer, &QTimer::timeout, this, &DetectLoop::mkdirs);
pcTimer->start(shared->postSecs * 1000);
if (shared->compCmd.toLower() != "null")
{
connect(evtTimer, &QTimer::timeout, this, &DetectLoop::reset);
evtTimer->setSingleShot(true);
eTimer.start();
}
else
{
eTimer.invalidate();
}
addPath(shared->buffPath + "/vid");
connect(this, &DetectLoop::started, this, &DetectLoop::startTimers);
connect(this, &DetectLoop::finished, this, &DetectLoop::stopTimers);
}
void DetectLoop::updated(const QString &path)
void DetectLoop::startTimers()
{
if (shared->compCmd.toLower() != "null")
pcTimer->start();
detTimer->start();
mkdirTimer->start();
}
void DetectLoop::stopTimers()
{
starvCnt = 0;
pcTimer->stop();
detTimer->stop();
mkdirTimer->stop();
evtTimer->stop();
if (rerun)
{
eTimer.start();
rerun = false;
run();
}
}
void DetectLoop::mkdirs()
{
auto timeStamp = QDateTime::currentDateTime();
auto path1 = shared->buffPath + "/img/" + timeStamp.toString(DATETIME_FMT);
timeStamp = timeStamp.addSecs(60);
auto path2 = shared->buffPath + "/img/" + timeStamp.toString(DATETIME_FMT);
if (!QFileInfo(path1).exists()) QDir().mkpath(path1);
if (!QFileInfo(path2).exists()) QDir().mkpath(path2);
}
QString DetectLoop::camCmdFromConf()
{
auto ret = "ffmpeg -hide_banner -y -i '" + shared->detectUri + "' -strftime 1 -strftime_mkdir 1 ";
if (shared->detectUri.contains("rtsp"))
{
ret += "-rtsp_transport udp ";
}
auto clips = lsFilesInDir(path, shared->streamExt);
auto index = clips.indexOf(vidBName);
ret += "-vf fps=1/" + QString::number(shared->secPerImg);
if (clips.size() - (index + 1) < 3)
if (shared->grayImg)
{
thread()->sleep(1);
ret += ",format=gray ";
}
else
{
vidAName = clips[clips.size() - 3];
vidBName = clips[clips.size() - 2];
vidAPath = shared->buffPath + "/vid/" + vidAName;
vidBPath = shared->buffPath + "/vid/" + vidBName;
exec(); thread()->sleep(1);
ret += " ";
}
ret += "img/" + QString(STRFTIME_FMT) + "/%S" + shared->imgExt;
qInfo() << ret;
return ret;
}
void DetectLoop::pcBreak()
@ -87,7 +128,14 @@ void DetectLoop::pcBreak()
if (evtTimer->isActive())
{
qInfo() << "motion detected, skipping the post command";
qInfo() << "motion detected, skipping the post command.";
}
else if (postProc->state() == QProcess::Running)
{
qInfo() << "previous post command is still running.";
qInfo() << "attempting to force close it.";
postProc->terminate();
}
else
{
@ -101,7 +149,7 @@ void DetectLoop::pcBreak()
}
else
{
QProcess::execute(args[0], args.mid(1));
postProc->start(args[0], args.mid(1));
}
}
}
@ -129,13 +177,54 @@ float DetectLoop::getFloatFromExe(const QByteArray &line)
if (!ok || strNum.isEmpty())
{
qCritical() << "err: the image comp command returned unexpected output and couldn't be converted to float";
qCritical() << "err: the image comp command returned unexpected output that couldn't be converted to float";
qCritical() << " raw output: " << line;
}
return res;
}
QString DetectLoop::statusLine()
{
if (starvCnt >= STARV_LIMIT)
{
return "STARVING";
}
else if (shared->compCmd == "null")
{
return "OFF ";
}
else
{
return getStatLineFromProc(this);
}
}
void DetectLoop::run()
{
auto cmdLine = camCmdFromConf();
auto args = parseArgs(cmdLine.toUtf8(), -1);
//qInfo() << "start image extraction command: " << cmdLine;
if (args.isEmpty())
{
qCritical() << "err: couldn't parse a program name from detectloop cmd: '" << cmdLine << "'";
}
else if (state() == QProcess::Running)
{
rerun = true; terminate();
}
else
{
setWorkingDirectory(shared->buffPath);
setProgram(args[0]);
setArguments(args.mid(1));
mkdirs();
start();
}
}
QStringList DetectLoop::buildArgs(const QString &prev, const QString &next)
{
auto args = parseArgs(shared->compCmd.toUtf8(), -1);
@ -149,98 +238,73 @@ QStringList DetectLoop::buildArgs(const QString &prev, const QString &next)
return args;
}
QStringList DetectLoop::buildSnapArgs(const QString &vidSrc, const QString &imgPath)
{
QStringList ret;
ret.append("-hide_banner");
ret.append("-loglevel");
ret.append("panic");
ret.append("-y");
ret.append("-i");
ret.append(vidSrc);
ret.append("-frames:v");
ret.append("1");
ret.append(imgPath);
return ret;
}
QString DetectLoop::statusLine()
{
if (!eTimer.isValid())
{
return "OFF ";
}
else if (eTimer.elapsed() >= 5000)
{
emit starving();
return "STARVED";
}
else
{
return "OK ";
}
}
void DetectLoop::reset()
{
evtOn = false;
}
void DetectLoop::exec()
{
auto imgAPath = shared->buffPath + "/img/" + QFileInfo(vidAPath).baseName() + ".bmp";
auto imgBPath = shared->buffPath + "/img/" + QFileInfo(vidBPath).baseName() + ".bmp";
auto snapArgsA = buildSnapArgs(vidAPath, imgAPath);
auto snapArgsB = buildSnapArgs(vidBPath, imgBPath);
auto compArgs = buildArgs(imgAPath, imgBPath);
if (compArgs.isEmpty())
if (starvCnt >= STARV_LIMIT)
{
qCritical() << "err: could not parse a executable name from img_comp_cmd: " << shared->compCmd;
if (detTimer->isActive())
{
emit starving();
}
}
else
{
QProcess::execute("ffmpeg", snapArgsA);
QProcess::execute("ffmpeg", snapArgsB);
auto timeStamp = QDateTime::currentDateTime();
auto path = shared->buffPath + "/img/" + timeStamp.toString(DATETIME_FMT);
auto imgList = lsFilesInDir(path, shared->imgExt);
if (QFile::exists(imgAPath) && QFile::exists(imgBPath) && (shared->compCmd.toLower() != "null"))
if (imgList.size() >= 2)
{
QProcess extComp;
starvCnt = 0;
extComp.start(compArgs[0], compArgs.mid(1));
extComp.waitForFinished();
float score = 0;
if (shared->outputType == "stdout")
if ((shared->compCmd != "null") && (postProc->state() != QProcess::Running))
{
score = getFloatFromExe(extComp.readAllStandardOutput());
}
else
{
score = getFloatFromExe(extComp.readAllStandardError());
}
auto imgA = path + "/" + imgList[imgList.size() - 2];
auto imgB = path + "/" + imgList[imgList.size() - 1];
auto compArgs = buildArgs(imgA, imgB);
qInfo() << compArgs.join(" ") << " --result: " << QString::number(score);
if ((score >= shared->imgThresh) || evtOn)
{
shared->recList.vidList.append(vidAPath);
shared->recList.vidList.append(vidBPath);
shared->recList.imgList.append(imgAPath);
shared->recList.imgList.append(imgBPath);
if (!evtTimer->isActive())
if (compArgs.isEmpty())
{
evtTimer->start(shared->evMaxSecs * 1000);
qCritical() << "err: could not parse a executable name from img_comp_cmd: " << shared->compCmd;
}
else
{
QProcess extComp;
extComp.start(compArgs[0], compArgs.mid(1));
extComp.waitForFinished();
float score = 0;
if (shared->outputType == "stdout")
{
score = getFloatFromExe(extComp.readAllStandardOutput());
}
else
{
score = getFloatFromExe(extComp.readAllStandardError());
}
qInfo() << compArgs.join(" ") << " --result: " << QString::number(score);
if ((score >= shared->imgThresh) || evtTimer->isActive())
{
if (!shared->recList.contains(timeStamp.toString(DATETIME_FMT)))
{
shared->recList.append(timeStamp.toString(DATETIME_FMT));
}
if (!evtTimer->isActive())
{
evtTimer->start();
}
}
}
}
}
else
{
starvCnt++;
}
}
vidAPath.clear();
vidBPath.clear();
}

View File

@ -15,38 +15,46 @@
#include "common.h"
class DetectLoop : public QFileSystemWatcher
#define STARV_LIMIT 24
class DetectLoop : public QProcess
{
Q_OBJECT
private:
QString vidAPath;
QString vidBPath;
QString vidAName;
QString vidBName;
QTimer *pcTimer;
QTimer *evtTimer;
QElapsedTimer eTimer;
shared_t *shared;
bool evtOn;
QTimer *pcTimer;
QTimer *evtTimer;
QTimer *detTimer;
QTimer *mkdirTimer;
shared_t *shared;
QProcess *postProc;
uint starvCnt;
bool rerun;
float getFloatFromExe(const QByteArray &line);
QString camCmdFromConf();
QStringList buildArgs(const QString &prev, const QString &next);
QStringList buildSnapArgs(const QString &vidSrc, const QString &imgPath);
private slots:
void exec();
void init();
void reset();
void pcBreak();
void updated(const QString &path);
void mkdirs();
void startTimers();
void stopTimers();
public slots:
void run();
public:
explicit DetectLoop(shared_t *shared, QThread *thr, QObject *parent = nullptr);
void exec();
~DetectLoop();
QString statusLine();
signals:

View File

@ -15,7 +15,7 @@
EventLoop::EventLoop(shared_t *sharedRes, QThread *thr, QObject *parent) : QObject(parent)
{
shared = sharedRes;
heartBeat = 1;
heartBeat = 3;
loopTimer = 0;
connect(thr, &QThread::started, this, &EventLoop::init);
@ -46,23 +46,31 @@ QString EventLoop::statusLine()
}
}
void EventLoop::deepListVids(QString &plsText)
{
auto txt = QTextStream(&plsText);
auto dirs = lsDirsInDir(shared->recPath + "/vid");
for (auto dir : dirs)
{
auto vids = lsFilesInDir(shared->recPath + "/vid/" + dir, shared->streamExt);
for (auto vid : vids)
{
txt << "#EXTINF:0" << Qt::endl;
txt << "vid/" << dir << "/" << vid << Qt::endl;
}
}
}
void EventLoop::updatePls()
{
QString text;
auto txt = QTextStream(&text);
txt << "#EXTM3U" << Qt::endl;
txt << "#EXT-X-VERSION:3" << Qt::endl;
txt << "#EXT-X-TARGETDURATION:4" << Qt::endl;
txt << "#EXT-X-MEDIA-SEQUENCE:0" << Qt::endl;
txt << "#EXTM3U" << Qt::endl;
auto vids = lsFilesInDir(shared->recPath + "/vid", shared->recExt);
for (auto vid : vids)
{
txt << "#EXTINF:4.000000," << Qt::endl;
txt << "vid/" << vid << Qt::endl;
}
deepListVids(text);
QFile file(shared->recPath + "/pls.m3u8");
@ -74,73 +82,83 @@ void EventLoop::updatePls()
file.close();
}
void EventLoop::exec()
void EventLoop::copyDirVid(const QString &src, const QString &dst)
{
bool dirUpdated = false;
auto vidList = lsFilesInDir(src, shared->streamExt);
if (!shared->recList.vidList.isEmpty())
for (auto vidName : vidList)
{
auto vidPath = shared->recList.vidList.takeFirst();
auto imgPath = shared->recList.imgList.takeFirst();
auto vidDst = shared->recPath + "/vid/" + QFileInfo(vidPath).fileName();
auto imgDst = shared->recPath + "/img/" + QFileInfo(imgPath).baseName() + shared->thumbExt;
QFile::copy(src + QDir::separator() + vidName, dst + QDir::separator() + vidName);
}
}
qInfo() << "---EVENT WRITEOUT START---";
qInfo() << "source files: ";
qInfo() << vidPath;
qInfo() << imgPath;
qInfo() << "destination files: ";
qInfo() << vidDst;
qInfo() << imgDst;
void EventLoop::copyDirImg(const QString &src, const QString &dst)
{
auto imgList = lsFilesInDir(src, shared->imgExt);
mkPath(shared->recPath + "/vid");
mkPath(shared->recPath + "/img");
for (auto imgName : imgList)
{
auto srcImgPath = src + QDir::separator() + imgName;
auto dstImgPath = dst + QDir::separator() + imgName;
if (!QFile::copy(vidPath, vidDst))
if (imgName.endsWith(shared->thumbExt))
{
qCritical() << "err: file copy operation failed." << Qt::endl;
qInfo() << "exists?: " << vidPath << ": " << QFileInfo::exists(vidPath);
}
if (imgPath.endsWith(shared->thumbExt))
{
QFile::copy(imgPath, imgDst);
QFile::copy(srcImgPath, dstImgPath);
}
else
{
QStringList args;
args << imgPath;
args << imgDst;
args << srcImgPath;
args << dstImgPath;
QProcess::execute("convert", args);
}
}
}
void EventLoop::exec()
{
bool dirUpdated = false;
if (!shared->recList.isEmpty())
{
auto timeStamp = shared->recList.takeFirst();
auto srcVidPath = shared->buffPath + "/vid/" + timeStamp;
auto srcImgPath = shared->buffPath + "/img/" + timeStamp;
auto dstVidPath = shared->recPath + "/vid/" + timeStamp;
auto dstImgPath = shared->recPath + "/img/" + timeStamp;
mkPath(dstVidPath);
mkPath(dstImgPath);
copyDirVid(srcVidPath, dstVidPath);
copyDirImg(srcImgPath, dstImgPath);
dirUpdated = true;
qInfo() << "---EVENT WRITEOUT END---";
}
else
{
// maintain max live secs worth of image files in the img buffer folder.
// maintain max live vid clips worth of img files on the buffer folder.
auto names = lsFilesInDir(shared->buffPath + "/img", ".bmp");
auto names = lsDirsInDir(shared->buffPath + "/img");
if (names.size() > (shared->liveSecs / 3))
if (names.size() > shared->liveMins)
{
QFile::remove(shared->buffPath + "/img/" + names[0]);
QDir(shared->buffPath + "/img/" + names[0]).removeRecursively();
names.removeFirst();
}
// maintain max live secs worth of video files in the vid buffer folder.
// maintain max live vid clips worth of video files in the vid buffer folder.
names = lsFilesInDir(shared->buffPath + "/vid", shared->streamExt);
names = lsDirsInDir(shared->buffPath + "/vid");
if (names.size() > (shared->liveSecs / 3))
if (names.size() > shared->liveMins)
{
QFile::remove(shared->buffPath + "/vid/" + names[0]);
QDir(shared->buffPath + "/vid/" + names[0]).removeRecursively();
names.removeFirst();
}
@ -148,17 +166,15 @@ void EventLoop::exec()
// maintain max byte size of the vid/img folder in the recording path.
auto names = lsFilesInDir(shared->recPath + "/vid", shared->recExt);
auto names = lsDirsInDir(shared->recPath + "/vid");
for (auto i = 0; (byteSize(shared->recPath) >= shared->evtMaxBytes); ++i)
{
auto nameOnly = QFileInfo(shared->recPath + "/vid/" + names[0]).baseName();
auto vidDir = shared->recPath + "/vid/" + names[0];
auto imgDir = shared->recPath + "/img/" + names[0];
auto vidFile = shared->recPath + "/vid/" + nameOnly + shared->recExt;
auto imgFile = shared->recPath + "/img/" + nameOnly + shared->thumbExt;
QFile::remove(vidFile);
QFile::remove(imgFile);
QDir(vidDir).removeRecursively();
QDir(imgDir).removeRecursively();
names.removeFirst();

View File

@ -31,6 +31,9 @@ private:
int heartBeat;
void updatePls();
void deepListVids(QString &plsText);
void copyDirImg(const QString &src, const QString &dst);
void copyDirVid(const QString &src, const QString &dst);
public:

View File

@ -25,10 +25,10 @@ void showHelp(const QString etcDir)
QTextStream(stdout) << "-u : uninstall the entire server from your system, including the service. all" << Qt::endl;
QTextStream(stdout) << " recorded footage and config files will remain." << Qt::endl;
QTextStream(stdout) << "-f : force an action without pausing for user confirmation." << Qt::endl;
QTextStream(stdout) << "-s : view the status of all camera instances." << Qt::endl;
QTextStream(stdout) << "-l : view the status of all camera instances." << Qt::endl;
QTextStream(stdout) << "-q : kill all camera instances." << Qt::endl;
QTextStream(stdout) << "-r : same as -d except it is non-blocking for starting all camera instances" << Qt::endl;
QTextStream(stdout) << " via systemd. same as 'systemctl start " << APP_TARGET << "'" << Qt::endl;
QTextStream(stdout) << "-s : same as -d except it is non-blocking for starting all camera instances" << Qt::endl;
QTextStream(stdout) << " via systemd. same as 'sudo systemctl start " << APP_TARGET << "'" << Qt::endl;
}
int main(int argc, char** argv)
@ -81,7 +81,7 @@ int main(int argc, char** argv)
{
if (args.contains("-f"))
{
ret = QProcess::execute("/opt/" + QString(APP_TARGET) + "/uninstall.sh", QStringList());
ret = QProcess::execute("sudo /opt/" + QString(APP_TARGET) + "/uninstall.sh", QStringList());
}
else
{
@ -92,11 +92,11 @@ int main(int argc, char** argv)
if (ans == 'y' || ans == 'Y')
{
ret = QProcess::execute("/opt/" + QString(APP_TARGET) + "/uninstall.sh", QStringList());
ret = QProcess::execute("sudo /opt/" + QString(APP_TARGET) + "/uninstall.sh", QStringList());
}
}
}
else if (args.contains("-s"))
else if (args.contains("-l"))
{
auto statFiles = lsFilesInDir(staDir);
@ -116,9 +116,9 @@ int main(int argc, char** argv)
QFile::remove(procFile);
QThread::currentThread()->sleep(5);
}
else if (args.contains("-r"))
else if (args.contains("-s"))
{
QProcess::execute("systemctl start " + QString(APP_TARGET));
QProcess::execute("sudo systemctl start " + QString(APP_TARGET));
}
else
{

View File

@ -23,12 +23,15 @@ RecordLoop::RecordLoop(shared_t *sharedRes, QThread *thr, QObject *parent) : QPr
connect(this, &RecordLoop::readyReadStandardOutput, this, &RecordLoop::resetTime);
connect(this, &RecordLoop::readyReadStandardError, this, &RecordLoop::resetTime);
connect(this, &RecordLoop::started, this, &RecordLoop::resetTime);
connect(this, &RecordLoop::finished, this, &RecordLoop::restart);
moveToThread(thr);
}
RecordLoop::~RecordLoop()
{
disconnect(this, &RecordLoop::finished, this, &RecordLoop::restart);
terminate();
waitForFinished();
}
@ -36,16 +39,39 @@ RecordLoop::~RecordLoop()
void RecordLoop::init()
{
checkTimer = new QTimer(this);
mkdirTimer = new QTimer(this);
sync = false;
connect(checkTimer, &QTimer::timeout, this, &RecordLoop::restart);
connect(mkdirTimer, &QTimer::timeout, this, &RecordLoop::mkdirs);
checkTimer->setSingleShot(true);
checkTimer->setInterval(3000);
connect(this, &RecordLoop::finished, mkdirTimer, &QTimer::stop);
connect(this, &RecordLoop::finished, checkTimer, &QTimer::stop);
mkdirTimer->setInterval(60000);
checkTimer->setInterval(60000);
// stall timeout. ffmpeg has a tendency to just 'stop.' meaning it would just stop
// reording at random times without stop signals or even error messages. this timer,
// monitors for any text output from ffmpeg. if no updates in 60000 msecs, it will
// automatically restart ffmpeg.
setupBuffDir(shared->buffPath);
restart();
}
void RecordLoop::mkdirs()
{
auto timeStamp = QDateTime::currentDateTime();
auto path1 = shared->buffPath + "/vid/" + timeStamp.toString(DATETIME_FMT);
timeStamp = timeStamp.addSecs(60);
auto path2 = shared->buffPath + "/vid/" + timeStamp.toString(DATETIME_FMT);
if (!QFileInfo(path1).exists()) QDir().mkpath(path1);
if (!QFileInfo(path2).exists()) QDir().mkpath(path2);
}
QString RecordLoop::camCmdFromConf()
{
auto ret = "ffmpeg -hide_banner -y -i '" + shared->recordUri + "' -strftime 1 -strftime_mkdir 1 ";
@ -60,9 +86,12 @@ QString RecordLoop::camCmdFromConf()
ret += "-vf fps=" + QString::number(shared->recFps) + ",scale=" + shared->recScale + " ";
}
auto maxClips = (60 / shared->liveClipSecs) * shared->liveMins;
ret += "-vcodec " + shared->vidCodec + " ";
ret += "-acodec " + shared->audCodec + " ";
ret += "-hls_time 3 -hls_list_size " + QString::number(shared->liveSecs / 3) + " -hls_flags delete_segments -hls_segment_filename vid/" + QString(STRFTIME_FMT) + shared->streamExt + " ";
ret += "-hls_time " + QString::number(shared->liveClipSecs) + " -hls_list_size " + QString::number(maxClips) + " ";
ret += "-hls_flags delete_segments -hls_segment_filename vid/" + QString(STRFTIME_FMT) + "/%S" + shared->streamExt + " ";
ret += "pls.m3u8";
qInfo() << ret;
@ -72,44 +101,66 @@ QString RecordLoop::camCmdFromConf()
QString RecordLoop::statusLine()
{
if (state() == QProcess::Running)
if (!sync)
{
return "OK ";
return "WAIT ";
}
else
{
return "FAIL";
return getStatLineFromProc(this);
}
}
void RecordLoop::synced()
{
sync = true;
restart();
}
void RecordLoop::resetTime()
{
// reset the stall timer to prevent it from timing out when ffmpeg doesn't
// need to be restarted.
checkTimer->start();
}
void RecordLoop::restart()
{
auto sec = QTime::currentTime().second();
if (state() == QProcess::Running)
{
terminate();
waitForFinished();
}
auto cmdLine = camCmdFromConf();
auto args = parseArgs(cmdLine.toUtf8(), -1);
//qInfo() << "start recording command: " << cmdLine;
if (args.isEmpty())
else if (!sync && (sec != 0))
{
qCritical() << "err: couldn't parse a program name";
// start recording when the seconds mark hit zero in real life.
QTimer::singleShot((60 - sec) * 1000, this, &RecordLoop::synced);
}
else
{
setWorkingDirectory(shared->buffPath);
setProgram(args[0]);
setArguments(args.mid(1));
auto cmdLine = camCmdFromConf();
auto args = parseArgs(cmdLine.toUtf8(), -1);
start();
//qInfo() << "start recording command: " << cmdLine;
if (args.isEmpty())
{
qCritical() << "err: couldn't parse a program name";
}
else
{
setWorkingDirectory(shared->buffPath);
setProgram(args[0]);
setArguments(args.mid(1));
mkdirs();
mkdirTimer->start();
start();
}
}
}

View File

@ -23,17 +23,22 @@ private slots:
void init();
void resetTime();
void mkdirs();
private:
shared_t *shared;
QTimer *checkTimer;
QTimer *mkdirTimer;
bool sync;
QString camCmdFromConf();
QString nearest15mins(QString &sec);
public slots:
void restart();
void synced();
public: