Я создаю домашнюю систему наблюдения своими руками из 30 камер ESP32 AI Thinker. Чтобы присоединиться ко всем и управлять ими, я создаю веб-приложение NodeJS — панель управления для просмотра всех камер, добавления/редактирования/удаления их. Каждая камера представляет собой чип ESP32, который передает потоковое видео в формате JPEG (кадры jpeg один раз в X мс). Каждая камера имеет свой собственный IP-адрес в домашней сети, а это означает, что у каждой камеры есть простой веб-сервер для просмотра видео. Но поскольку это ESP32 и простой веб-сервер, к веб-серверу не может быть двух или более подключений. Допустим, у меня есть вкладка браузера, подключенная к стримеру, и когда я дублирую эту вкладку, загрузка занимает бесконечное время, и сразу после закрытия первой вкладки - вторая вкладка показывает видео. Панель инструментов камер должна иметь возможность отображать несколько подключений, но, столкнувшись с проблемой веб-соединения ESP32, я решил сделать что-то вроде повторного стримера на Python или nodejs, который будет «читать» со всех камер (поэтому каждая камера будет иметь 1 соединение) и повторно выполнить потоковую передачу на несколько соединений.
Опять же, поток камер ESP32, повторный стример считывает каждую камеру и выполняет повторную потоковую передачу, панель мониторинга считывает данные из повторного стримера.
Идеальный дизайн – это:
Дашборд готов — это обычный CRUD с дополнительным сервисом — смотрите камеру
ESP32 готов.
Осталось написать этот рестример и я потратил около недели на поиск решения. Проблема в том, что ничто из того, что я пробовал, не может прочитать поток JPEG. Да, это поток JPEG, а не, скажем, протокол RTSP или HSP. Когда я сворачиваю IP-адрес камеры, я вижу тип контента image/jpeg.
Может ли кто-нибудь сказать мне, как прочитать и повторно перевести поток JPEG, чтобы я мог показать его на панели инструментов? FFmpeg не помог
Формат потока — просто image/jpeg. А когда вы запрашиваете камеру по ее IP, на самом деле ответ так и не завершился. Идет бесконечная загрузка с потоковой передачей изображений в формате jpeg... Вот и результат. alexey@alexey-ASUS:~$ curl --raw 192.168.2.160 33 Тип контента: image/jpeg Длина контента: 17452 442c Внимание: двоичный вывод может испортить ваш терминал. Используйте «--output -», чтобы указать Предупреждение: Curl все равно выводить его на терминал, или рассмотрите «--output Предупреждение: <FILE>», чтобы сохранить в файл.
А, вы говорите о en.wikipedia.org/wiki/Motion_JPEG#Video_streaming ? например stackoverflow.com/questions/77657572/… ?
Точно. Но есть проблема — чтобы показать видео в теге <img> — нужно отправить запрос на сервер. В моем случае сервером является ESP32, который может параллельно обрабатывать только одно соединение. А если я открою свой дашборд на 3 ПК, то только один сможет показывать видео, а 2 будут грузиться бесконечно





Итак, через месяц я наконец нашел решение. Результат здесь: https://github.com/Electr0Hub/HomeSauron-Aggregator
Сделал следующим образом: Есть команда, которая представляет собой процесс (разновидность ремесленных команд laravel). Эта команда просыпается, получает все камеры из базы данных, проходит по всем камерам с помощью цикла и вызывает другую команду для каждой камеры, т. е. запускает независимые команды и передает идентификатор камеры этой команде. Дочерний (который независим) получает URL-адрес камеры из БД, используя свой идентификатор, выполняет бесконечное чтение и записывает фрагменты в публикацию/подписку Redis. Итак, в целом у меня есть одна «родительская» команда, которая запускает все «дочерние» команды для пробуждения, чтения и записи в Redis. Очень просто.
Я сделал это в Laravel с его командами Artisan. Так что я:
рестриминг PHP Artisan: камеры
public function handle()
{
// Kill all previous instances of the `restream:camera` command
$this->killPreviousProcesses();
// Retrieve all cameras
$cameras = Camera::select('id')->get();
foreach ($cameras as $camera) {
$cameraId = $camera->id;
$this->info("Starting restream for camera ID: $cameraId");
// Execute the restream:camera command in the background
exec("php artisan restream:camera --camera = {$cameraId} > /dev/null &");
}
}
/**
* Kill all previous instances of the `restream:camera` command.
*/
protected function killPreviousProcesses(): void
{
// Get all the PIDs of `restream:camera` processes
exec("ps aux | grep 'php artisan restream:camera ' | grep -v grep | awk '{print $2}'", $output);
foreach ($output as $pid) {
// Kill each process
exec("kill -9 $pid");
}
}
И дочерний — php artisan restream:camera --camera = {CAMERAID}
public function handle()
{
try {
// Retrieve the camera option
$cameraOption = $this->option('camera');
// Check if the camera option is provided
if (!$cameraOption) {
$this->error('The --camera option is required.');
return;
}
// Fetch the camera(s) based on the option
$camera = Camera::find($cameraOption);
if (is_null($camera)) {
$this->error('No cameras found with the provided option.');
return;
}
$client = new Client();
$this->restreamCamera($client, $camera);
}
catch (\Exception $exception) {
Log::channel("streams")->error($exception);
throw $exception;
}
}
protected function restreamCamera(Client $client, Camera $camera): void
{
$url = $camera->url;
$client->getAsync($url, [
'stream' => true,
'sink' => fopen('php://temp', 'r+')
])->then(
function (Response $response) use ($camera) {
$this->info('Streaming frame from ' . $camera->id);
$body = $response->getBody();
$frameBuffer = '';
while (!$body->eof()) {
// Read a chunk of data
$frame = $body->read(1024); // Adjust the buffer size as needed
$frameBuffer .= $frame;
// Check if the buffer contains more than one boundary
if (substr_count($frameBuffer, '123456789000000000000987654321') > 1) {
// Extract data between boundaries
$jpegData = $this->getDataBetweenBoundary($frameBuffer, '123456789000000000000987654321', '123456789000000000000987654321');
// Find the JPEG start marker
$jpegStart = strpos($jpegData, "\xFF\xD8");
if ($jpegStart !== false) {
// Extract the JPEG data from the start marker
$jpegData = substr($jpegData, $jpegStart);
// Publish the JPEG data to Redis
$dataToPublish = [
'frame' => base64_encode($jpegData),
'camera' => [
'id' => $camera->id,
'name' => $camera->name,
'url' => $camera->url,
],
];
Redis::publish("camera_stream:$camera->id", json_encode($dataToPublish));
// Reset the buffer to remove processed data
$frameBuffer = substr($frameBuffer, strpos($frameBuffer, '123456789000000000000987654321', strpos($frameBuffer, '123456789000000000000987654321') + 1));
}
}
}
},
function ($error) {
// Handle the error
echo "Error: " . $error->getMessage();
}
);
}
protected function getDataBetweenBoundary($string, $start, $end){
$string = ' ' . $string;
$ini = strpos($string, $start);
if ($ini == 0) return '';
$ini += strlen($start);
$len = strpos($string, $end, $ini) - $ini;
return substr($string, $ini, $len);
}
нет документации по формату потока? можешь опубликовать, что произойдет, если ты это
curl --raw?