前回の投稿 MediaMTXで映像配信 で、映像を配信しましたが、今度は静止画を配信します。
静止画として以下の4種類の方法を実装しました。
① 別のサーバにアップロードした静止画を配信
② 別のサーバにUDPで送信した静止画を配信
③ MediaMTXが動作しているDockerに配置した静止画を配信
④ ブラウザからWebRTCで静止画を送信
①と②は、別のサーバをMJPEG配信サーバにすることで実現しています。
③は、ffmpegの機能を活用します。
MediaMTXから静止画を取りに行くため、MediaMTXのWebAPIを使ったpathの登録作業が必要です。
④は、ブラウザのWebRTCとCanvasを利用牛ます。
配信した静止画・動画は、別のブラウザからWebRTCで参照することができます。
ソースコードもろもろは以下に置きました。
①別のサーバにアップロードした静止画を配信
別のサーバに静止画をアップロード
③は直接エクスプローラ等で、MediaMTXサーバにファイルをアップロードしておいたのを配信するのに対して、①は別のサーバに一時的にファイルを適宜アップロードします。
以下、サーバ側の実装です。
exports.handler = async (event, context, callback) => {
// console.log(event);
// console.log(context);
if( event.path == "/mjpeg-upload"){
if( event.body.length <= 0 )
throw new Error("body invalid");
var name = event.queryStringParameters["name"];
if( !stream_list[name] ){
stream_list[name] = {
type: "file",
buffer: event.body,
client_list: [],
};
}else{
if( stream_list[name].type != "file")
throw new Error("Type mismatch");
stream_list[name].buffer = event.body;
}
return new Response({});
}else
{
throw new Error("Unknown endpoint");
}
};
MJPEGとして配信
上げたファイルを動画として参照するため、MJPEGにします。
以下、サーバ側の実装です。
exports.stream_handler = awslambda.streamifyResponse(async (event, responseStream, context) => {
console.log(event.queryStringParameters);
var interval = event.queryStringParameters["interval"] ? parseInt(event.queryStringParameters["interval"]) : UPDATE_INTERVAL;
var name = event.queryStringParameters["name"];
var type = event.queryStringParameters["type"] || "file";
const BOUNDARY = crypto.randomUUID();
responseStream = awslambda.HttpResponseStream.from(responseStream, {
statusCode: 200,
headers: {
"Content-Type": "multipart/x-mixed-replace; boundary=" + BOUNDARY,
}
});
if (!stream_list[name]) {
stream_list[name] = {
type: type,
buffer: waitingBuffer,
client_list: [],
};
}
if( !stream_list[name].interval ){
if( type == "udp"){
const udp_send = () => {
socket.send(JSON.stringify({
ipaddress: ip.address(),
port: UDP_RECV_PORT
}), UDP_SEND_PORT, name);
};
stream_list[name].interval = setInterval(udp_send, interval);
udp_send();
}else if( type == 'file'){
const file_send = async () =>{
await putFrame(name);
};
stream_list[name].interval = setInterval(file_send, interval);
}
}
stream_list[name].client_list.push({
stream: responseStream,
boundary: BOUNDARY
});
responseStream.on('close', () => {
console.log('Client disconnected.');
var index = stream_list[name].client_list.findIndex(item => item.boundary == BOUNDARY);
if (index >= 0)
stream_list[name].client_list.splice(index, 1);
if (stream_list[name].client_list.length <= 0) {
if( stream_list[name].interval ){
clearInterval(stream_list[name].interval);
stream_list[name].interval = 0;
}
console.log("interval stoped");
}
});
await putFrame(name);
// responseStream.end();
});
async function putFrame(name) {
var target = stream_list[name];
if (target && target.buffer ) {
var type = await fileTypeFromBuffer(target.buffer);
for (let item of target.client_list) {
try {
item.stream.write(`--${item.boundary}\r\n`);
item.stream.write(`Content-Type: ${type.mime}\r\n`);
item.stream.write(`Content-Length: ${target.buffer.length}\r\n`);
item.stream.write(`\r\n`);
item.stream.write(target.buffer);
item.stream.write(`\r\n`);
} catch (error) {
console.error(error);
}
}
}
}
HTTPStreamResponse状態にしてから、5秒ごとにBOUNDARY付きでJPEGファイルをresponseに書き込んでいます。
これにより、静止画のWebRTCを参照中に、適宜表示しているファイルを変更することができます。
②別のサーバにUDPで送信した静止画を配信
突然UDPが出てきましたが、ESP32という小さなCPUがカメラを持っていて、定期的に撮影画像をアップしたかったのですが、連続的に通信するには、UDPが都合がよいためです。
5秒ごとに、サーバからESP32にUDPで撮影を依頼すると、ESP32から撮影画像がUDPで返ってくるようにしました。
UDP受信すると以下が呼ばれて、putFrameしています。
exports.udp_handler = async (event, context) => {
// console.log(event);
var name = event.remote.address;
if (!stream_list[name])
return;
var target = stream_list[name];
if (!target.reader)
target.reader = new LineStreamReader(null);
let {
done,
value
} = target.reader.push(event.body);
if (value) {
// console.log(value);
target.buffer = Buffer.from(value, 'base64');
putFrame(name);
} else if (done) {}
};
③MediaMTXが動作しているDockerに配置した静止画を配信
QNAPのフォルダのファイル一覧取得
QNAPのフォルダにあるファイル一覧を取得するには、QNAPへのログインとQNAP File APIを呼び出す必要があります。
QNAPへログインすると、sidが取得され、それをFile APIに指定すると、認証されたユーザとして一覧取得します。
async function call_signin(user, password){
var encpass = ezEncode(utf16to8(password));
var url = `${base_url}/cgi-bin/authLogin.cgi?user=${user}&pwd=${encpass}`;
return fetch(url, {
method: "GET",
cache: "no-store"
})
.then(response =>{
return response.text();
});
}
async function signin(user, password){
var xmlString = await call_signin(user, password);
var result = await new Promise((resolve, reject) =>{
xml2js.parseString(xmlString, (err, result) =>{
if( err )
return reject(err);
resolve(result);
});
});
return result.QDocRoot.authSid[0];
}
取得したsidで指定するフォルダパスのファイル一覧を取得します。
https://download.qnap.com/dev/QNAP_QTS_File_Station_API_v5.pdf
async function get_list(sid, path){
var url = `${base_url}/cgi-bin/filemanager/utilRequest.cgi?func=get_list&sid=${sid}&path=${encodeURIComponent(path)}&start=0&limit=1000`;
var result = await fetch(url, {
method: "GET",
cache: "no-store"
})
.then(response =>{
return response.json();
});
console.log(result);
return result.datas.map(item => { return { fname: item.filename, fsize: item.filesize } } );
}
MediaMTXにpathを追加
MediaMTXにpathを追加するライブラリを作ってみました。
async path_add(name, input){
var input = {
url: this.base_url + '/v3/config/paths/add/' + name,
method: 'POST',
body: input,
headers: {
Authorization: "Basic " + btoa(this.user + ":" + this.password)
},
response_type: "text"
};
var result = await HttpUtils.do_http(input);
return result;
}
こんな感じで登録します。
var body = JSON.parse(event.body);
var type = mimetypes.lookup(body.fname);
var runOnDemand;
if( type.startsWith("image/") )
runOnDemand = construct_ffmpeg_image(body.fname);
else if( type.startsWith("video/") )
runOnDemand = construct_ffmpeg_mpeg(body.fname);
else
throw new Error("file invalid");
var result = await mediamtx.path_add(body.path, {
name: body.path,
runOnDemand: runOnDemand
});
console.log(result);
MediaMTXで指定するffmpegのパラメータは、動画・静止画に合わせて以下のようにしました。ファイルの拡張子を見て、画像ファイルか動画ファイルを判別しています。
function construct_ffmpeg_image(fname){
return `ffmpeg -loop 1 -i ${mediamtx_media_dir}/${fname} -c:v libx264 -preset veryfast -tune stillimage -pix_fmt yuv420p -f rtsp -rtsp_transport tcp rtsp://${MEDIAMTX_USER_USER}:${MEDIAMTX_USER_PASSWORD}@127.0.0.1:$RTSP_PORT/$MTX_PATH`;
}
function construct_ffmpeg_mpeg(fname){
return `ffmpeg -stream_loop -1 -re -i ${mediamtx_media_dir}/${fname} -c:v libx264 -preset veryfast -x264opts "bframes=0:scenecut=0" -pix_fmt yuv420p -c:a libopus -b:a 64k -f rtsp -rtsp_transport tcp rtsp://${MEDIAMTX_USER_USER}:${MEDIAMTX_USER_PASSWORD}@127.0.0.1:$RTSP_PORT/$MTX_PATH`;
}
MediaMTXサーバからMjpegを取得
mediamtx.ymlに以下のpathを追加することで、クライアントから接続要求を受けた時にのみMjpegサーバに取りに行ってくれます。
paths:
imagefile:
runOnDemand: >
ffmpeg -re -i http://[別サーバのIPアドレス]:40080/mjpeg-get?name=$MTX_PATH&type=file
-c:v libx264 -preset veryfast -tune zerolatency -g 1
-pix_fmt yuv420p -profile:v baseline -color_range pc -an -err_detect ignore_err
-f rtsp -rtsp_transport tcp rtsp://[Userのユーザ名]:[Userのパスワード]@127.0.0.1:$RTSP_PORT/$MTX_PATH
④ブラウザからWebRTCで静止画を送信
ブラウザのJavascriptを使ってファイルから画像を読み込み、Canvasに展開します。
一方でこのCanvasに対して、captureStream(1)を呼び出してStreamを生成し、それをWebRTCに接続します。
ただ、これだけではだめで、定期的に画像をCanvasに展開することで、WebRTCとして送信されるようになります。
const canvas = document.querySelector('#localimage_view_' + index);
const update_image = () =>{
if( source.image ){
const ctx = canvas.getContext('2d');
canvas.width = source.image.naturalWidth;
canvas.height = source.image.naturalHeight;
ctx.drawImage(source.image, 0, 0, canvas.width, canvas.height);
}
};
source.interval = setInterval(update_image, 1000);
update_image();
var stream = canvas.captureStream(1);
var input = {
stream: stream,
user: this.config.user,
password: this.config.password,
name: source.path,
timeout: 5000
};
var peer = await webrtc_send_connect(input, (module, event) =>{
console.log(module, event);
if (module == 'peer' &&
(event.type == "connectionstatechange" &&
(event.connectionState == "disconnected" || event.connectionState == "failed" || event.connectionState == "closed")) ){
clearInterval(source.interval);
source.interval = null;
source.peer = null;
}
});
this.$set(this.source_list[index], "peer", peer);
MediaMTXの認証にHTTPを使う
以下のようにすると、ユーザ認証をサーバで実装でき、柔軟性が増します。
authMethod: http
authHTTPAddress: http://【別のサーバ】/mediamtx-auth
authHTTPExclude:
- action: metrics
- action: pprof
以下のように実装しました。API系はAdminメンバのみにしました。
if( event.path == '/mediamtx-auth' ){
if( body.action == 'api' ){
if( body.user == MEDIAMTX_ADMIN_USER && body.password == MEDIAMTX_ADMIN_PASSWORD ){
return new Response({});
}
console.log("auth failed");
return new Response({}, 401 );
}else{
if( ( body.user == MEDIAMTX_ADMIN_USER && body.password == MEDIAMTX_ADMIN_PASSWORD ) ||
( body.user == MEDIAMTX_USER_USER && body.password == MEDIAMTX_USER_PASSWORD )){
return new Response({});
}
console.log("auth failed");
return new Response({}, 401 );
}
}else
そうすると、一般ユーザがpathのリストがわからないので、別途取得できるエンドポイントを用意しました。
if( event.path == '/mediamtx-get-path'){
var user = event.requestContext.basicAuth.basic[0];
var password = event.requestContext.basicAuth.basic[1];
if( !( user == MEDIAMTX_ADMIN_USER && password == MEDIAMTX_ADMIN_PASSWORD ) &&
!( user == MEDIAMTX_USER_USER && password == MEDIAMTX_USER_PASSWORD ) ){
throw new Error("invalid user");
}
var result = await mediamtx.path_list();
console.log(result);
return new Response({ list: result.items });
}else
一方の、ESP32は、以下のJavascript環境を使っています。
https://booth.pm/ja/items/3735175
ESP32側のソースはこんな感じです。
import * as camera from "Camera";
import * as utils from "Utils";
import * as udp from "Udp";
console.log("main.js start");
console.log(JSON.stringify(esp32.getMemoryUsage()));
camera.start(camera.MODEL_M5STACK_V2_PSRAM, camera.FRAMESIZE_VGA);
console.log(JSON.stringify(camera.getParameter()));
udp.recvBegin(1234);
function loop(){
esp32.update();
var recv = udp.checkRecvText();
if( recv ){
try{
console.log("recvText");
console.log(JSON.stringify(recv));
var target = JSON.parse(recv.payload);
console.log(JSON.stringify(target));
var buffer = camera.getPicture();
var base64 = utils.base64Encode(new Uint8Array(buffer));
console.log(base64.length);
var step = 512;
for( var index = 0 ; index < base64.length ; index += step ){
var subtext = base64.substr(index, step )
udp.sendText(target.ipaddress, target.port, subtext);
// console.log(subtext);
}
udp.sendText(target.ipaddress, target.port, "\n");
console.log('udpSend called');
}catch(error){
console.error(error);
}
}
}
ブラウザによる管理コンソール
ブラウザから簡単に管理したり、WebRTCの配信を視聴することができるようにしておきました。
PCに接続したカメラ映像を配信したい場合は、httpsにする必要があります。
【管理コンソール】
https://[別のサーバ]/mediamtx_console/
【Viewer】
https://[別のサーバ]/mediamtx_viewer/
以上


