0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MediaMTXで静止画を配信

Last updated at Posted at 2025-12-21

前回の投稿 MediaMTXで映像配信 で、映像を配信しましたが、今度は静止画を配信します。
静止画として以下の4種類の方法を実装しました。

① 別のサーバにアップロードした静止画を配信
② 別のサーバにUDPで送信した静止画を配信
③ MediaMTXが動作しているDockerに配置した静止画を配信
④ ブラウザからWebRTCで静止画を送信

①と②は、別のサーバをMJPEG配信サーバにすることで実現しています。
③は、ffmpegの機能を活用します。
MediaMTXから静止画を取りに行くため、MediaMTXのWebAPIを使ったpathの登録作業が必要です。
④は、ブラウザのWebRTCとCanvasを利用牛ます。

配信した静止画・動画は、別のブラウザからWebRTCで参照することができます。

image.png

ソースコードもろもろは以下に置きました。

①別のサーバにアップロードした静止画を配信

別のサーバに静止画をアップロード

③は直接エクスプローラ等で、MediaMTXサーバにファイルをアップロードしておいたのを配信するのに対して、①は別のサーバに一時的にファイルを適宜アップロードします。
以下、サーバ側の実装です。

/nodejs/api/controllers/mediamtx-auth/index.js
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にします。
以下、サーバ側の実装です。

/nodejs/api/controllers/mediamtx-auth/index.js
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しています。

/nodejs/api/controllers/mjpeg-stream/index.js
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に指定すると、認証されたユーザとして一覧取得します。

sidの取得
https://github.com/mcaldwell85/seatec/blob/master/axis%20reference%20guide/QNAP%20File%20Station%20Web%20API.txt

/nodejs/api/controllers/mediamtx-auth/qnap_files.js
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

/nodejs/api/controllers/mediamtx-auth/index.js
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を追加するライブラリを作ってみました。

/nodejs/api/controllers/mediamtx-auth/mediamtx_utils.js
  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;
  }

こんな感じで登録します。

/nodejs/api/controllers/mediamtx-auth/index.js
    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のパラメータは、動画・静止画に合わせて以下のようにしました。ファイルの拡張子を見て、画像ファイルか動画ファイルを判別しています。

/nodejs/api/controllers/mediamtx-auth/index.js
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サーバに取りに行ってくれます。

mediamtx.yml
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として送信されるようになります。

public/mediamtx_console/js/start.js
    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を使う

以下のようにすると、ユーザ認証をサーバで実装でき、柔軟性が増します。

/mediamtx/mediamtx.yml
authMethod: http
authHTTPAddress: http://【別のサーバ】/mediamtx-auth
authHTTPExclude:
- action: metrics
- action: pprof

以下のように実装しました。API系はAdminメンバのみにしました。

/nodejs/api/controllers/mediamtx-auth/index.js
  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のリストがわからないので、別途取得できるエンドポイントを用意しました。

/nodejs/api/controllers/mediamtx-auth/index.js
  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側のソースはこんな感じです。

/esp32/main.js
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/

image.png

【Viewer】
https://[別のサーバ]/mediamtx_viewer/

image.png

以上

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?