From eed71b66d4888c6fdd8d7d98b7f31d18de80f0e8 Mon Sep 17 00:00:00 2001 From: Thulinma Date: Mon, 24 Jun 2024 12:04:00 +0200 Subject: [PATCH] (M)JPG HTTP output support --- generated/meson.build | 2 - lib/util.cpp | 18 ++ lib/util.h | 1 + meson_options.txt | 1 - src/input/input_ebml.cpp | 10 ++ src/output/meson.build | 5 +- src/output/output_ebml.cpp | 2 - src/output/output_ebml.h | 1 - src/output/output_jpg.cpp | 333 ++++++------------------------------- src/output/output_jpg.h | 19 +-- 10 files changed, 92 insertions(+), 300 deletions(-) diff --git a/generated/meson.build b/generated/meson.build index 7180c0d7..e3690758 100644 --- a/generated/meson.build +++ b/generated/meson.build @@ -32,8 +32,6 @@ embed_files = [ {'infile': '../embed/min/skins/default.css', 'variable': 'skin_default_css', 'outfile': 'skin_default.css.h'}, {'infile': '../embed/min/skins/dev.css', 'variable': 'skin_dev_css', 'outfile': 'skin_dev.css.h'}, {'infile': '../embed/skins/video-js.css', 'variable': 'skin_videojs_css', 'outfile': 'skin_videojs.css.h'}, - {'infile': '../src/output/noffmpeg.jpg', 'variable': 'noffmpeg', 'outfile': 'noffmpeg.h'}, - {'infile': '../src/output/noh264.jpg', 'variable': 'noh264', 'outfile': 'noh264.h'}, ] embed_tgts = [] diff --git a/lib/util.cpp b/lib/util.cpp index 304e6c2e..012ea106 100644 --- a/lib/util.cpp +++ b/lib/util.cpp @@ -316,6 +316,24 @@ namespace Util{ rndSrc.close(); } + /// Secure random alphanumeric string generator + /// Uses getRandomBytes internally + std::string getRandomAlphanumeric(size_t len){ + std::string ret(len, 'X'); + getRandomBytes((void*)ret.data(), len); + for (size_t i = 0; i < len; ++i){ + uint8_t v = (ret[i] % 62); + if (v < 10){ + ret[i] = v + '0'; + }else if (v < 36){ + ret[i] = v - 10 + 'A'; + }else{ + ret[i] = v - 10 - 26 + 'a'; + } + } + return ret; + } + /// 64-bits version of ftell uint64_t ftell(FILE *stream){ /// \TODO Windows implementation (e.g. _ftelli64 ?) diff --git a/lib/util.h b/lib/util.h index 12a1d567..c656edf2 100644 --- a/lib/util.h +++ b/lib/util.h @@ -21,6 +21,7 @@ namespace Util{ int64_t expBackoffMs(const size_t currIter, const size_t maxIter, const int64_t maxWait); void getRandomBytes(void * dest, size_t len); + std::string getRandomAlphanumeric(size_t len); uint64_t ftell(FILE *stream); uint64_t fseek(FILE *stream, uint64_t offset, int whence); diff --git a/meson_options.txt b/meson_options.txt index 035a3b76..4cc1e075 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -21,7 +21,6 @@ option('DEBUG', description: 'Default debug level. Recommended value for develop option('NOGA', description: 'Disables Google Analytics entirely in the LSP', type: 'boolean', value: false) option('LOAD_BALANCE', description: 'Build the load balancer (WIP)', type: 'boolean', value: false) option('WITH_AV', description: 'Build a generic libav-based input (not distributable!)', type: 'boolean', value: false) -option('WITH_JPG', description: 'Build JPG thumbnailer output support (WIP)', type: 'boolean', value: false) option('WITH_SANITY', description: 'Enable MistOutSanityCheck output for testing purposes', type: 'boolean', value: false) option('LSP_MINIFY', description: 'Try to minify LSP JS via java closure-compiler, generally not needed unless changing JS code as a minified version is part of the repository already', type: 'boolean', value: false) option('LOCAL_GENERATORS', description: 'Attempts to find a locally-installed version of sourcery and make_html, instead of compiling it', type: 'boolean', value: false) diff --git a/src/input/input_ebml.cpp b/src/input/input_ebml.cpp index c38615e9..3fe9632e 100644 --- a/src/input/input_ebml.cpp +++ b/src/input/input_ebml.cpp @@ -381,6 +381,16 @@ namespace Mist{ trueCodec = "JPEG"; trueType = "video"; } + if (codec == "V_MS/VFW/FOURCC"){ + tmpElem = E.findChild(EBML::EID_CODECPRIVATE); + if (tmpElem){ + std::string bitmapheader = tmpElem.getValStringUntrimmed(); + if (bitmapheader.substr(16, 4) == "MJPG"){ + trueCodec = "JPEG"; + trueType = "video"; + } + } + } if (codec == "A_PCM/FLOAT/IEEE"){ trueCodec = "FLOAT"; trueType = "audio"; diff --git a/src/output/meson.build b/src/output/meson.build index cfd12eee..80a3c8fe 100644 --- a/src/output/meson.build +++ b/src/output/meson.build @@ -22,6 +22,7 @@ outputs = [ {'name' : 'SDP', 'format' : 'sdp', 'extra': ['http']}, {'name' : 'HTTP', 'format' : 'http_internal', 'extra': ['http','embed']}, {'name' : 'JSONLine', 'format' : 'jsonline'}, + {'name' : 'JPG', 'format' : 'jpg', 'extra': ['http']}, ] if usessl @@ -39,10 +40,6 @@ if have_srt outputs += {'name' : 'TSSRT', 'format' : 'tssrt', 'extra': ['ts', 'debased', 'with_srt']} endif -if get_option('WITH_JPG') - outputs += {'name' : 'JPG', 'format' : 'jpg', 'extra': ['http','embed']} -endif - if get_option('WITH_SANITY') outputs += {'name' : 'SanityCheck', 'format' : 'sanitycheck'} endif diff --git a/src/output/output_ebml.cpp b/src/output/output_ebml.cpp index 9aa150f2..5641090e 100644 --- a/src/output/output_ebml.cpp +++ b/src/output/output_ebml.cpp @@ -119,8 +119,6 @@ namespace Mist{ cfg->addOption("target", opt); } - bool OutEBML::isRecording(){return config->getString("target").size();} - /// Calculates the size of a Cluster (contents only) and returns it. /// Bases the calculation on the currently selected tracks and the given start/end time for the /// cluster. diff --git a/src/output/output_ebml.h b/src/output/output_ebml.h index 292b5b33..fed6208c 100644 --- a/src/output/output_ebml.h +++ b/src/output/output_ebml.h @@ -19,7 +19,6 @@ namespace Mist{ } private: - bool isRecording(); std::string doctype; void sendElemTrackEntry(size_t idx); size_t sizeElemTrackEntry(size_t idx); diff --git a/src/output/output_jpg.cpp b/src/output/output_jpg.cpp index 5a28e235..8c8bcd87 100644 --- a/src/output/output_jpg.cpp +++ b/src/output/output_jpg.cpp @@ -1,5 +1,4 @@ #include "output_jpg.h" -#include #include #include #include @@ -9,129 +8,76 @@ namespace Mist{ OutJPG::OutJPG(Socket::Connection &conn) : HTTPOutput(conn){ - HTTP = false; - cachedir = config->getString("cachedir"); - if (cachedir.size()){ - cachedir += "/MstJPEG" + streamName; - cachetime = config->getInteger("cachetime"); - }else{ - cachetime = 0; + motion = false; + if (isRecording()){ + motion = (config->getString("target").find(".mj") != std::string::npos); } - if (config->getString("target").size()){ - initialize(); - if (!streamName.size()){ - WARN_MSG("Recording unconnected JPG output to file! Cancelled."); - conn.close(); - return; - } - if (!M){ - INFO_MSG("Stream not available - aborting"); - conn.close(); - return; - } - if (!userSelect.size()){ - INFO_MSG("Stream codec not supported - aborting"); - conn.close(); - return; - } - // We generate a thumbnail first, then output it if successful - generate(); - if (!jpg_buffer.str().size()){ - // On failure, report, but do not open the file or write anything - FAIL_MSG("Could not generate thumbnail for %s", streamName.c_str()); - myConn.close(); - return; - } - if (config->getString("target") == "-"){ - INFO_MSG("Outputting %s to stdout in JPG format", streamName.c_str()); - }else{ - if (!connectToFile(config->getString("target"))){ - myConn.close(); - return; - } - INFO_MSG("Recording %s to %s in JPG format", streamName.c_str(), config->getString("target").c_str()); - } - myConn.SendNow(jpg_buffer.str().c_str(), jpg_buffer.str().size()); + } + + void OutJPG::respondHTTP(const HTTP::Parser & req, bool headersOnly){ + // Set global defaults + HTTPOutput::respondHTTP(req, headersOnly); + + motion = (req.url.find(".mj") != std::string::npos); + if (motion){ + boundary = Util::getRandomAlphanumeric(24); + H.SetHeader("Content-Type", "multipart/x-mixed-replace;boundary="+boundary); + H.SetHeader("Connection", "close"); + } + + H.CleanPreserveHeaders(); + H.SendResponse("200", "OK", myConn); + if (headersOnly){return;} + if (motion){ + myConn.SendNow("\r\n--" + boundary + "\r\nContent-Type: image/jpeg\r\n\r\n"); + } + parseData = true; + wantRequest = false; + } + + void OutJPG::sendNext(){ + char *dataPointer = 0; + size_t len = 0; + thisPacket.getString("data", dataPointer, len); + myConn.SendNow(dataPointer, len); + if (!motion){ + Util::logExitReason(ER_CLEAN_EOF, "end of single JPG frame"); myConn.close(); - return; + }else{ + myConn.SendNow("\r\n--" + boundary + "\r\nContent-Type: image/jpeg\r\n\r\n"); } } /// Pretends the stream is always ready to play - we don't care about waiting times or whatever bool OutJPG::isReadyForPlay(){return true;} - void OutJPG::initialSeek(bool dryRun){ - size_t mainTrack = getMainSelectedTrack(); - if (mainTrack == INVALID_TRACK_ID){return;} - INFO_MSG("Doing initial seek"); - if (M.getLive()){ - liveSeek(); - uint32_t targetKey = M.getKeyIndexForTime(mainTrack, currentTime()); - seek(M.getTimeForKeyIndex(mainTrack, targetKey)); - return; - } - // cancel if there are no keys in the main track - if (!M.getValidTracks().count(mainTrack) || !M.getLastms(mainTrack)){ - WARN_MSG("Aborted vodSeek because no tracks selected"); - return; - } - - uint64_t seekPos = M.getFirstms(mainTrack) + (M.getLastms(mainTrack) - M.getFirstms(mainTrack)) / 2; - bool didSeek = false; - size_t retries = 10; - while (!didSeek && --retries){ - MEDIUM_MSG("VoD seek to %" PRIu64 "ms", seekPos); - uint32_t targetKey = M.getKeyIndexForTime(mainTrack, seekPos); - didSeek = seek(M.getTimeForKeyIndex(mainTrack, targetKey)); - if (!didSeek){ - selectDefaultTracks(); - mainTrack = getMainSelectedTrack(); - } - seekPos = M.getFirstms(mainTrack) + (M.getLastms(mainTrack) - M.getFirstms(mainTrack)) * (((double)retries)/10.0); - } - if (!didSeek){ - onFail("Could not seek to location for image"); - } - } - void OutJPG::init(Util::Config *cfg){ HTTPOutput::init(cfg); capa["name"] = "JPG"; - capa["desc"] = "Allows getting a representative key frame as JPG image. Requires ffmpeg (with " - "h264 decoding and jpeg encoding) to be " - "installed in the PATH."; - capa["url_rel"] = "/$.jpg"; - capa["url_match"] = "/$.jpg"; - capa["codecs"][0u][0u].append("H264"); + capa["desc"] = "Support both single-frame JPEG and motion JPEG (e.g. MJPEG) over HTTP"; + capa["url_rel"].append("/$.jpg"); + capa["url_rel"].append("/$.mjpg"); + capa["url_match"].append("/$.jpg"); + capa["url_match"].append("/$.jpeg"); + capa["url_match"].append("/$.mjpg"); + capa["url_match"].append("/$.mjpeg"); + capa["codecs"][0u][0u].append("JPEG"); capa["methods"][0u]["handler"] = "http"; capa["methods"][0u]["type"] = "html5/image/jpeg"; - capa["methods"][0u]["hrn"] = "JPEG"; - capa["methods"][0u]["priority"] = 0; + capa["methods"][0u]["hrn"] = "JPEG image"; + capa["methods"][0u]["url_rel"] = "/$.jpg"; + capa["methods"][0u]["priority"] = 1; + capa["methods"][1u]["handler"] = "http"; + capa["methods"][1u]["type"] = "html5/image/jpeg"; + capa["methods"][1u]["hrn"] = "JPEG stream"; + capa["methods"][1u]["url_rel"] = "/$.mjpg"; + capa["methods"][1u]["priority"] = 2; config->addStandardPushCapabilities(capa); capa["push_urls"].append("/*.jpg"); + capa["push_urls"].append("/*.jpeg"); + capa["push_urls"].append("/*.mjpg"); + capa["push_urls"].append("/*.mjpeg"); - capa["optional"]["cachedir"]["name"] = "Cache directory"; - capa["optional"]["cachedir"]["help"] = - "Location to store cached images, preferably in RAM somewhere"; - capa["optional"]["cachedir"]["option"] = "--cachedir"; - capa["optional"]["cachedir"]["short"] = "D"; - capa["optional"]["cachedir"]["default"] = "/tmp"; - capa["optional"]["cachedir"]["type"] = "string"; - capa["optional"]["cachetime"]["name"] = "Cache time"; - capa["optional"]["cachetime"]["help"] = - "Duration in seconds to wait before refreshing cached images. Does not apply to VoD " - "streams (VoD is cached infinitely)"; - capa["optional"]["cachetime"]["option"] = "--cachetime"; - capa["optional"]["cachetime"]["short"] = "T"; - capa["optional"]["cachetime"]["default"] = 30; - capa["optional"]["cachetime"]["type"] = "uint"; - capa["optional"]["ffopts"]["name"] = "Ffmpeg arguments"; - capa["optional"]["ffopts"]["help"] = - "Extra arguments to use when generating the jpg file through ffmpeg"; - capa["optional"]["ffopts"]["option"] = "--ffopts"; - capa["optional"]["ffopts"]["short"] = "F"; - capa["optional"]["ffopts"]["default"] = "-qscale:v 4"; - capa["optional"]["ffopts"]["type"] = "string"; cfg->addOptionsFromCapabilities(capa); JSON::Value opt; @@ -142,175 +88,4 @@ namespace Mist{ cfg->addOption("target", opt); } - void OutJPG::onHTTP(){ - std::string method = H.method; - H.clearHeader("Range"); - H.clearHeader("Icy-MetaData"); - H.clearHeader("User-Agent"); - H.setCORSHeaders(); - if (method == "OPTIONS" || method == "HEAD"){ - H.SetHeader("Content-Type", "image/jpeg"); - H.protocol = "HTTP/1.1"; - H.SendResponse("200", "OK", myConn); - H.Clean(); - return; - } - initialize(); - if (!userSelect.size()){ - H.protocol = "HTTP/1.0"; - H.setCORSHeaders(); - H.body.clear(); - H.SendResponse("200", "Unprocessable: not H264", myConn); -#include "noh264.h" - myConn.SendNow(noh264, noh264_len); - myConn.close(); - return; - } - H.SetHeader("Content-Type", "image/jpeg"); - H.protocol = "HTTP/1.0"; - H.setCORSHeaders(); - H.StartResponse(H, myConn); - HTTP = true; - generate(); - if (!jpg_buffer.str().size()){ - NoFFMPEG(); - }else{ - H.Chunkify(jpg_buffer.str().c_str(), jpg_buffer.str().size(), myConn); - if (cachedir.size()){ - std::ofstream cachefile; - cachefile.open(cachedir.c_str()); - cachefile << jpg_buffer.str(); - cachefile.close(); - } - } - H.Chunkify("", 0, myConn); - H.Clean(); - HTTP = false; - } - - void OutJPG::NoFFMPEG(){ - FAIL_MSG("Could not start ffmpeg! Is it installed on the system?"); -#include "noffmpeg.h" - if (HTTP){ - H.Chunkify(noffmpeg, noffmpeg_len, myConn); - }else{ - myConn.SendNow(noffmpeg, noffmpeg_len); - } - } - - void OutJPG::generate(){ - // If we're caching, check if the cache hasn't expired yet... - if (cachedir.size() && cachetime){ - struct stat statData; - if (stat(cachedir.c_str(), &statData) != -1){ - if (Util::epoch() - statData.st_mtime <= cachetime || M.getVod()){ - std::ifstream cachefile; - cachefile.open(cachedir.c_str()); - char buffer[8 * 1024]; - while (cachefile.good() && myConn){ - cachefile.read(buffer, 8 * 1024); - uint32_t s = cachefile.gcount(); - if (HTTP){ - H.Chunkify(buffer, s, myConn); - }else{ - myConn.SendNow(buffer, s); - } - } - cachefile.close(); - return; - } - } - } - - initialSeek(); - size_t mainTrack = getMainSelectedTrack(); - if (mainTrack == INVALID_TRACK_ID){ - FAIL_MSG("Could not select valid track"); - return; - } - - int fin = -1, fout = -1, ferr = 2; - pid_t ffmpeg = -1; - // Start ffmpeg quietly if we're < MEDIUM debug level - char ffcmd[256]; - ffcmd[255] = 0; // ensure there is an ending null byte - snprintf(ffcmd, 255, "ffmpeg %s -f h264 -i - %s -vframes 1 -f mjpeg -", - (Util::printDebugLevel >= DLVL_MEDIUM ? "" : "-v quiet"), - config->getString("ffopts").c_str()); - - HIGH_MSG("Starting JPG command: %s", ffcmd); - char *args[128]; - uint8_t argCnt = 0; - char *startCh = 0; - for (char *i = ffcmd; i - ffcmd < 256; ++i){ - if (!*i){ - if (startCh){args[argCnt++] = startCh;} - break; - } - if (*i == ' '){ - if (startCh){ - args[argCnt++] = startCh; - startCh = 0; - *i = 0; - } - }else{ - if (!startCh){startCh = i;} - } - } - args[argCnt] = 0; - - ffmpeg = Util::Procs::StartPiped(args, &fin, &fout, &ferr); - if (ffmpeg < 2){ - Socket::Connection failure(fin, fout); - failure.close(); - NoFFMPEG(); - return; - } - VERYHIGH_MSG("Started ffmpeg, PID %" PRIu64 ", pipe %" PRIu32 "/%" PRIu32, (uint64_t)ffmpeg, - (uint32_t)fin, (uint32_t)fout); - Socket::Connection ffconn(fin, -1); - - // Send H264 init data in Annex B format - MP4::AVCC avccbox; - avccbox.setPayload(M.getInit(mainTrack)); - ffconn.SendNow(avccbox.asAnnexB()); - INSANE_MSG("Sent init data to ffmpeg..."); - - if (ffconn && prepareNext() && thisPacket){ - uint64_t keytime = thisPacket.getTime(); - do{ - char *p = 0; - size_t l = 0; - uint32_t o = 0; - thisPacket.getString("data", p, l); - // Send all NAL units in the key frame, in Annex B format - while (o + 4 < l){ - // get NAL unit size - uint32_t s = Bit::btohl(p + o); - // make sure we don't go out of bounds of packet - if (o + s + 4 > l){break;} - // Send H264 Annex B start code - ffconn.SendNow("\000\000\000\001", 4); - // Send NAL unit - ffconn.SendNow(p + o + 4, s); - INSANE_MSG("Sent h264 %" PRIu32 "b NAL unit to ffmpeg (time: %" PRIu64 ")...", s, - thisPacket.getTime()); - // Skip to next NAL unit - o += s + 4; - } - INSANE_MSG("Sent whole packet, checking next..."); - }while (ffconn && prepareNext() && thisPacket && thisPacket.getTime() == keytime); - } - ffconn.close(); - // Output ffmpeg result data to socket - jpg_buffer.clear(); - Socket::Connection ffout(-1, fout); - while (myConn && ffout && (ffout.spool() || ffout.Received().size())){ - while (myConn && ffout.Received().size()){ - jpg_buffer << ffout.Received().get(); - ffout.Received().get().clear(); - } - } - ffout.close(); - } }// namespace Mist diff --git a/src/output/output_jpg.h b/src/output/output_jpg.h index e47b4d40..e23a209b 100644 --- a/src/output/output_jpg.h +++ b/src/output/output_jpg.h @@ -1,23 +1,20 @@ #include "output_http.h" -#include namespace Mist{ class OutJPG : public HTTPOutput{ public: OutJPG(Socket::Connection &conn); static void init(Util::Config *cfg); - void onHTTP(); + void respondHTTP(const HTTP::Parser & req, bool headersOnly); + void sendNext(); bool isReadyForPlay(); - - private: - void generate(); - void initialSeek(bool dryRun = false); - void NoFFMPEG(); - std::string cachedir; - uint64_t cachetime; - bool HTTP; - std::stringstream jpg_buffer; + protected: + virtual bool isFileTarget(){return isRecording();} + virtual bool inlineRestartCapable() const{return true;} + bool motion; + std::string boundary; }; }// namespace Mist typedef Mist::OutJPG mistOut; +