From c465ad63544d23175742df61be166115da93ef4e Mon Sep 17 00:00:00 2001 From: Daniel Golle Date: Mon, 19 Oct 2026 12:01:31 +0100 Subject: [PATCH 1/5] gst1-plugins-base: package the device monitor Package gst-device-monitor for camera capability discovery and track the tool selection in the build configuration. Signed-off-by: Daniel Golle --- multimedia/gst1-plugins-base/Makefile | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/multimedia/gst1-plugins-base/Makefile b/multimedia/gst1-plugins-base/Makefile index 5949d2cb0953be..2f6439b5d95ab4 100644 --- a/multimedia/gst1-plugins-base/Makefile +++ b/multimedia/gst1-plugins-base/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gst1-plugins-base PKG_VERSION:=1.28.7 -PKG_RELEASE:=1 +PKG_RELEASE:=2 PKG_SOURCE:=gst-plugins-base-$(PKG_VERSION).tar.xz PKG_SOURCE_URL:=https://gstreamer.freedesktop.org/src/gst-plugins-base @@ -27,12 +27,14 @@ include $(INCLUDE_DIR)/nls.mk include $(INCLUDE_DIR)/meson.mk PKG_CONFIG_DEPENDS:= \ + CONFIG_PACKAGE_gst1-device-monitor \ CONFIG_PACKAGE_gst1-mod-alsa \ CONFIG_PACKAGE_gst1-mod-app \ CONFIG_PACKAGE_gst1-mod-audioconvert \ CONFIG_PACKAGE_gst1-mod-audiorate \ CONFIG_PACKAGE_gst1-mod-audioresample \ CONFIG_PACKAGE_gst1-mod-audiotestsrc \ + CONFIG_PACKAGE_gst1-mod-encoding \ CONFIG_PACKAGE_gst1-mod-playback \ CONFIG_PACKAGE_gst1-mod-gio \ CONFIG_PACKAGE_gst1-mod-ogg \ @@ -63,6 +65,16 @@ define Package/gstreamer1-base/description/Default GStreamer open source multimedia framework endef +define Package/gst1-device-monitor + $(call Package/gstreamer1-base/Default) + TITLE+= device discovery utility +endef + +define Package/gst1-device-monitor/description + List devices, their capabilities and GStreamer source properties using + the upstream gst-device-monitor-1.0 utility. +endef + define Package/gst1-plugins-base $(call Package/gstreamer1-base/Default) @@ -169,7 +181,7 @@ MESON_ARGS += \ \ -Dexamples=disabled \ -Dtests=disabled \ - -Dtools=disabled \ + -Dtools=$(if $(CONFIG_PACKAGE_gst1-device-monitor),en,dis)abled \ -Dintrospection=disabled \ -Dnls=enabled \ -Dorc=disabled \ @@ -221,6 +233,12 @@ define Package/gstreamer1-plugins-base/install true endef +define Package/gst1-device-monitor/install + $(INSTALL_DIR) $(1)/usr/bin + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/gst-device-monitor-$(GST_VERSION) \ + $(1)/usr/bin/ +endef + # 1: short name # 2: description # 3: dependencies on other gstreamer libraries (short name) @@ -338,3 +356,4 @@ $(eval $(call GstBuildPlugin,vorbis,Vorbis,audio tag,ogg,+libvorbis)) $(eval $(call BuildPackage,gstreamer1-plugins-base)) $(eval $(call BuildPackage,gst1-plugins-base)) +$(eval $(call BuildPackage,gst1-device-monitor)) From 2f21408f6e7c0361ca1fa43b579fa055e0bd0e88 Mon Sep 17 00:00:00 2001 From: Daniel Golle Date: Sat, 17 Oct 2026 03:17:22 +0100 Subject: [PATCH 2/5] gst1-plugins-base: heap-allocate the RTSP message arrays The RTSP connection and watch code allocates the serialised messages, the output vectors and the map infos of a send on the stack, sized by the number of RTP packets. A 4K H.265 frame is over a thousand packets and needs about 250 KB, so on musl's 128 KiB thread stacks the server's send thread runs past the guard page into the heap and the server crashes at PLAY. Move the arrays of the four send paths to the heap. Signed-off-by: Daniel Golle --- multimedia/gst1-plugins-base/Makefile | 2 +- .../patches/010-rtsp-heap-arrays.patch | 160 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 multimedia/gst1-plugins-base/patches/010-rtsp-heap-arrays.patch diff --git a/multimedia/gst1-plugins-base/Makefile b/multimedia/gst1-plugins-base/Makefile index 2f6439b5d95ab4..0efb885eed6c84 100644 --- a/multimedia/gst1-plugins-base/Makefile +++ b/multimedia/gst1-plugins-base/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gst1-plugins-base PKG_VERSION:=1.28.7 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_SOURCE:=gst-plugins-base-$(PKG_VERSION).tar.xz PKG_SOURCE_URL:=https://gstreamer.freedesktop.org/src/gst-plugins-base diff --git a/multimedia/gst1-plugins-base/patches/010-rtsp-heap-arrays.patch b/multimedia/gst1-plugins-base/patches/010-rtsp-heap-arrays.patch new file mode 100644 index 00000000000000..b454028fbe9f99 --- /dev/null +++ b/multimedia/gst1-plugins-base/patches/010-rtsp-heap-arrays.patch @@ -0,0 +1,160 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Daniel Golle +Date: Thu, 29 Oct 2026 18:34:34 +0000 +Subject: [PATCH] rtsp: allocate the message arrays on the heap + +gst_rtsp_connection_send_messages_usec(), gst_rtsp_source_dispatch_write(), +gst_rtsp_watch_write_serialized_messages() and +gst_rtsp_watch_send_messages() allocate the serialized messages, the +output vectors and the map infos with g_newa(). Their sizes grow with +the number of RTP packets in a frame: a 4K H.265 frame of 1.5 MB is +1100 packets and needs about 250 KB on the stack of the server's send +thread. On systems whose default thread stack is small, such as musl +with 128 KiB, the stack pointer jumps past the guard page into the +neighbouring heap mapping and the arrays overwrite the GstBuffers of +the very packets being sent, which shows up as GST_IS_BUFFER and +GST_MINI_OBJECT_IS_LOCKABLE assertions and a crash at PLAY. Use the +heap for the four arrays and free them on every exit. + +Signed-off-by: Daniel Golle +--- + gst-libs/gst/rtsp/gstrtspconnection.c | 48 ++++++++++++++++----------- + 1 file changed, 29 insertions(+), 19 deletions(-) + +--- a/gst-libs/gst/rtsp/gstrtspconnection.c ++++ b/gst-libs/gst/rtsp/gstrtspconnection.c +@@ -2127,9 +2127,7 @@ gst_rtsp_connection_send_messages_usec ( + g_return_val_if_fail (conn != NULL, GST_RTSP_EINVAL); + g_return_val_if_fail (messages != NULL || n_messages == 0, GST_RTSP_EINVAL); + +- serialized_messages = g_newa (GstRTSPSerializedMessage, n_messages); +- memset (serialized_messages, 0, +- sizeof (GstRTSPSerializedMessage) * n_messages); ++ serialized_messages = g_new0 (GstRTSPSerializedMessage, n_messages); + + for (i = 0, n_vectors = 0, n_memories = 0, bytes_to_write = 0; i < n_messages; + i++) { +@@ -2202,8 +2200,8 @@ gst_rtsp_connection_send_messages_usec ( + } + } + +- vectors = g_newa (GOutputVector, n_vectors); +- map_infos = n_memories ? g_newa (GstMapInfo, n_memories) : NULL; ++ vectors = g_new (GOutputVector, n_vectors); ++ map_infos = n_memories ? g_new (GstMapInfo, n_memories) : NULL; + + for (i = 0, j = 0, k = 0; i < n_messages; i++) { + vectors[j].buffer = serialized_messages[i].data_is_data_header ? +@@ -2266,6 +2264,9 @@ gst_rtsp_connection_send_messages_usec ( + + g_free (serialized_messages[i].data); + } ++ g_free (map_infos); ++ g_free (vectors); ++ g_free (serialized_messages); + + return res; + +@@ -2274,6 +2275,7 @@ no_message: + for (i = 0; i < n_messages; i++) { + gst_rtsp_serialized_message_clear (&serialized_messages[i]); + } ++ g_free (serialized_messages); + g_warning ("Wrong message"); + return GST_RTSP_EINVAL; + } +@@ -4307,11 +4309,9 @@ gst_rtsp_source_dispatch_write (GPollabl + } + } + +- vectors = g_newa (GOutputVector, n_vectors); +- map_infos = n_memories ? g_newa (GstMapInfo, n_memories) : NULL; +- ids = n_ids ? g_newa (guint, n_ids + 1) : NULL; +- if (ids) +- memset (ids, 0, sizeof (guint) * (n_ids + 1)); ++ vectors = g_new (GOutputVector, n_vectors); ++ map_infos = n_memories ? g_new (GstMapInfo, n_memories) : NULL; ++ ids = n_ids ? g_new0 (guint, n_ids + 1) : NULL; + + for (i = 0, j = 0, n_mmap = 0, l = 0, bytes_to_write = 0; i < n_messages; + i++) { +@@ -4460,16 +4460,21 @@ gst_rtsp_source_dispatch_write (GPollabl + + /* notify all messages that were successfully written */ + if (ids) { +- while (*ids) { ++ guint *id = ids; ++ ++ while (*id) { + /* only decrease the counter for messages that have an id. Only + * the last message of a messages chunk is counted */ + watch->messages_count--; + + if (watch->funcs.message_sent) +- watch->funcs.message_sent (watch, *ids, watch->user_data); +- ids++; ++ watch->funcs.message_sent (watch, *id, watch->user_data); ++ id++; + } + } ++ g_free (ids); ++ g_free (map_infos); ++ g_free (vectors); + + if (res == GST_RTSP_EINTR) { + goto write_blocked; +@@ -4778,8 +4783,8 @@ gst_rtsp_watch_write_serialized_messages + } + } + +- vectors = g_newa (GOutputVector, n_vectors); +- map_infos = n_memories ? g_newa (GstMapInfo, n_memories) : NULL; ++ vectors = g_new (GOutputVector, n_vectors); ++ map_infos = n_memories ? g_new (GstMapInfo, n_memories) : NULL; + + for (i = 0, j = 0, k = 0, bytes_to_write = 0; i < n_messages; i++) { + vectors[j].buffer = messages[i].data_is_data_header ? +@@ -4827,6 +4832,8 @@ gst_rtsp_watch_write_serialized_messages + for (k = 0; k < n_memories; k++) { + gst_memory_unmap (map_infos[k].memory, &map_infos[k]); + } ++ g_free (map_infos); ++ g_free (vectors); + + if (res != GST_RTSP_EINTR) { + /* actual error or done completely */ +@@ -5073,27 +5080,30 @@ gst_rtsp_watch_send_messages (GstRTSPWat + guint n_messages, guint * id) + { + GstRTSPSerializedMessage *serialized_messages; ++ GstRTSPResult res; + gint i; + + g_return_val_if_fail (watch != NULL, GST_RTSP_EINVAL); + g_return_val_if_fail (messages != NULL || n_messages == 0, GST_RTSP_EINVAL); + +- serialized_messages = g_newa (GstRTSPSerializedMessage, n_messages); +- memset (serialized_messages, 0, +- sizeof (GstRTSPSerializedMessage) * n_messages); ++ serialized_messages = g_new0 (GstRTSPSerializedMessage, n_messages); + + for (i = 0; i < n_messages; i++) { + if (!serialize_message (watch->conn, &messages[i], &serialized_messages[i])) + goto error; + } + +- return gst_rtsp_watch_write_serialized_messages (watch, serialized_messages, ++ res = gst_rtsp_watch_write_serialized_messages (watch, serialized_messages, + n_messages, id); ++ g_free (serialized_messages); ++ ++ return res; + + error: + for (i = 0; i < n_messages; i++) { + gst_rtsp_serialized_message_clear (&serialized_messages[i]); + } ++ g_free (serialized_messages); + + return GST_RTSP_EINVAL; + } From 294c812e946deb2ebe5ab7181cc37c125ba2e55e Mon Sep 17 00:00:00 2001 From: Daniel Golle Date: Mon, 19 Oct 2026 12:01:31 +0100 Subject: [PATCH 3/5] gst1-plugins-bad: package Unix FD transport Expose unixfdsink and unixfdsrc as an optional plugin package for sharing encoded streams and DMA buffer descriptors between consumers. Signed-off-by: Daniel Golle --- multimedia/gst1-plugins-bad/Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/multimedia/gst1-plugins-bad/Makefile b/multimedia/gst1-plugins-bad/Makefile index e21787c7b73273..0fd27bd691d556 100644 --- a/multimedia/gst1-plugins-bad/Makefile +++ b/multimedia/gst1-plugins-bad/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gst1-plugins-bad PKG_VERSION:=1.28.7 -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_SOURCE:=gst-plugins-bad-$(PKG_VERSION).tar.xz PKG_SOURCE_URL:=https://gstreamer.freedesktop.org/src/gst-plugins-bad/ @@ -22,6 +22,8 @@ PKG_LICENSE:=LGPL-2.1-or-later GPL-2.0-or-later PKG_LICENSE_FILES:=COPYING.LIB COPYING PKG_CPE_ID:=cpe:/a:freedesktop:gst-plugins-bad +PKG_CONFIG_DEPENDS += CONFIG_PACKAGE_gst1-mod-unixfd + include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/nls.mk include $(INCLUDE_DIR)/meson.mk @@ -152,6 +154,7 @@ MESON_ARGS += \ $(call GST_COND_SELECT,subenc) \ $(call GST_COND_SELECT,switchbin) \ $(call GST_COND_SELECT,timecode) \ + $(call GST_COND_SELECT,unixfd) \ -Dvideofilters=$(if $(CONFIG_PACKAGE_gst1-mod-videofiltersbad),en,dis)abled \ $(call GST_COND_SELECT,videoframe_audiolevel) \ -Dvideoparsers=$(if $(CONFIG_PACKAGE_gst1-mod-videoparsersbad),en,dis)abled \ @@ -429,6 +432,7 @@ $(eval $(call GstBuildPlugin,speed,speed support,audio,,)) $(eval $(call GstBuildPlugin,subenc,subenc support,controller,,)) $(eval $(call GstBuildPlugin,switchbin,switchbin support,,,)) $(eval $(call GstBuildPlugin,timecode,timecode support,,,)) +$(eval $(call GstBuildPlugin,unixfd,Unix file descriptor transport support,allocators,,)) $(eval $(call GstBuildPlugin,v4l2codecs,V4L2 stateless codecs support,codecs,,+libgudev)) $(eval $(call GstBuildPlugin,videofiltersbad,videofiltersbad support,,,)) $(eval $(call GstBuildPlugin,videoframe_audiolevel,videoframe_audiolevel support,,,)) From 58243aba0469e45908e0d51f10119d3b77764a32 Mon Sep 17 00:00:00 2001 From: Daniel Golle Date: Thu, 29 Oct 2026 12:46:17 +0000 Subject: [PATCH 4/5] gst1-plugins-good: count the dmabuf offset in bytesused Importing a dmabuf memory that starts at an offset, the chroma plane of a single-dmabuf NV12 frame into a two-plane NM12 encoder queue, fails at QBUF with EINVAL: the allocator rewrites bytesused from the memory size alone and the kernel sees data_offset above bytesused. Add the memory offset back at QBUF, as the import already did. Signed-off-by: Daniel Golle --- multimedia/gst1-plugins-good/Makefile | 2 +- ...r-count-the-data-offset-in-bytesused.patch | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 multimedia/gst1-plugins-good/patches/010-v4l2allocator-count-the-data-offset-in-bytesused.patch diff --git a/multimedia/gst1-plugins-good/Makefile b/multimedia/gst1-plugins-good/Makefile index 53d622886f2be5..3a1ba21c739d29 100644 --- a/multimedia/gst1-plugins-good/Makefile +++ b/multimedia/gst1-plugins-good/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gst1-plugins-good PKG_VERSION:=1.28.7 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_SOURCE:=gst-plugins-good-$(PKG_VERSION).tar.xz PKG_SOURCE_URL:=https://gstreamer.freedesktop.org/src/gst-plugins-good/ diff --git a/multimedia/gst1-plugins-good/patches/010-v4l2allocator-count-the-data-offset-in-bytesused.patch b/multimedia/gst1-plugins-good/patches/010-v4l2allocator-count-the-data-offset-in-bytesused.patch new file mode 100644 index 00000000000000..a74b928b627bcb --- /dev/null +++ b/multimedia/gst1-plugins-good/patches/010-v4l2allocator-count-the-data-offset-in-bytesused.patch @@ -0,0 +1,37 @@ +From 54d5bbcec34d76ecfa80c9fe7432b94f9ed4beb2 Mon Sep 17 00:00:00 2001 +From: Daniel Golle +Date: Thu, 29 Oct 2026 12:39:17 +0000 +Subject: [PATCH] v4l2allocator: Count the data offset in bytesused + +An imported dmabuf memory can start at an offset inside the dmabuf, +which the import stores as the plane's data_offset and as the memory +offset. At QBUF the allocator rewrites bytesused from the memory size +alone, so for a chroma plane that shares its luma's dmabuf bytesused +ends below data_offset and the kernel rejects the buffer with EINVAL. +Add the memory offset back, as the import did: bytesused counts from +the start of the plane buffer. + +Signed-off-by: Daniel Golle +--- + sys/v4l2/gstv4l2allocator.c | 10 +++++++--- + 1 file changed, 7 insertions(+), 3 deletions(-) + +--- a/sys/v4l2/gstv4l2allocator.c ++++ b/sys/v4l2/gstv4l2allocator.c +@@ -1265,9 +1265,13 @@ gst_v4l2_allocator_qbuf (GstV4l2Allocato + + /* update sizes */ + if (V4L2_TYPE_IS_MULTIPLANAR (obj->type)) { +- for (i = 0; i < group->n_mem; i++) +- group->planes[i].bytesused = +- gst_memory_get_sizes (group->mem[i], NULL, NULL); ++ for (i = 0; i < group->n_mem; i++) { ++ gsize size, offset; ++ ++ /* bytesused counts from the start of the plane, data_offset included */ ++ size = gst_memory_get_sizes (group->mem[i], &offset, NULL); ++ group->planes[i].bytesused = size + offset; ++ } + } else { + group->buffer.bytesused = gst_memory_get_sizes (group->mem[0], NULL, NULL); + } From 1e8e4a1ec140664d387785aea95418c0849d5a8e Mon Sep 17 00:00:00 2001 From: Daniel Golle Date: Thu, 29 Oct 2026 21:33:18 +0000 Subject: [PATCH 5/5] gst1-rtsp-server: add package Package the RTSP server library and its test-launch example, and add a procd service that discovers cameras and hardware encoders on video4linux and media hotplug events, keeps them in UCI, and runs one capture pipeline per camera with the capture, ISP output selection and hardware encoding of all its enabled streams. Encoded frames reach one RTSP server instance per stream over Unix FD sockets. Generated streams use the sensor-native size, the first hardware codec and a constant bitrate derived from the output size and frame rate; a camera section can carry a complete pipeline override. The negotiated stream parameters are published for an ONVIF server. The server library allocates the data list messages of a frame on the heap, as a 4K frame overflows musl's thread stack otherwise. Signed-off-by: Daniel Golle --- multimedia/gst1-rtsp-server/Makefile | 125 ++++ multimedia/gst1-rtsp-server/README | 261 ++++++++ multimedia/gst1-rtsp-server/files/capture.uc | 77 +++ multimedia/gst1-rtsp-server/files/config.uc | 207 ++++++ multimedia/gst1-rtsp-server/files/discover.uc | 50 ++ .../gst1-rtsp-server/files/gst-rtsp.config | 1 + .../gst1-rtsp-server/files/gst-rtsp.hotplug | 5 + .../gst1-rtsp-server/files/gst-rtsp.init | 89 +++ multimedia/gst1-rtsp-server/files/model.uc | 625 ++++++++++++++++++ .../gst1-rtsp-server/files/onvif-model.uc | 134 ++++ multimedia/gst1-rtsp-server/files/onvif.uc | 74 +++ multimedia/gst1-rtsp-server/files/pipeline.uc | 25 + multimedia/gst1-rtsp-server/files/watch.uc | 56 ++ ...ient-do_send_data_list-heap-messages.patch | 36 + multimedia/gst1-rtsp-server/tests/model.uc | 303 +++++++++ multimedia/gst1-rtsp-server/tests/onvif.uc | 105 +++ .../gst1-rtsp-server/tests/transport.py | 126 ++++ multimedia/gst1-rtsp-server/tests/watch.uc | 42 ++ 18 files changed, 2341 insertions(+) create mode 100644 multimedia/gst1-rtsp-server/Makefile create mode 100644 multimedia/gst1-rtsp-server/README create mode 100644 multimedia/gst1-rtsp-server/files/capture.uc create mode 100644 multimedia/gst1-rtsp-server/files/config.uc create mode 100644 multimedia/gst1-rtsp-server/files/discover.uc create mode 100644 multimedia/gst1-rtsp-server/files/gst-rtsp.config create mode 100644 multimedia/gst1-rtsp-server/files/gst-rtsp.hotplug create mode 100644 multimedia/gst1-rtsp-server/files/gst-rtsp.init create mode 100644 multimedia/gst1-rtsp-server/files/model.uc create mode 100644 multimedia/gst1-rtsp-server/files/onvif-model.uc create mode 100644 multimedia/gst1-rtsp-server/files/onvif.uc create mode 100644 multimedia/gst1-rtsp-server/files/pipeline.uc create mode 100644 multimedia/gst1-rtsp-server/files/watch.uc create mode 100644 multimedia/gst1-rtsp-server/patches/001-rtsp-client-do_send_data_list-heap-messages.patch create mode 100644 multimedia/gst1-rtsp-server/tests/model.uc create mode 100644 multimedia/gst1-rtsp-server/tests/onvif.uc create mode 100644 multimedia/gst1-rtsp-server/tests/transport.py create mode 100644 multimedia/gst1-rtsp-server/tests/watch.uc diff --git a/multimedia/gst1-rtsp-server/Makefile b/multimedia/gst1-rtsp-server/Makefile new file mode 100644 index 00000000000000..c73c0ef60261b1 --- /dev/null +++ b/multimedia/gst1-rtsp-server/Makefile @@ -0,0 +1,125 @@ +# +# This is free software, licensed under the GNU General Public License v2. +# See /LICENSE for more information. +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=gst1-rtsp-server +PKG_VERSION:=1.28.7 +PKG_RELEASE:=1 + +PKG_SOURCE:=gst-rtsp-server-$(PKG_VERSION).tar.xz +PKG_SOURCE_URL:=https://gstreamer.freedesktop.org/src/gst-rtsp-server +PKG_HASH:=de43a59a0c89a14eb1c14ae8e81e5b45c4e9d7b1f930b0ebaad360f9b5e0b5c4 +PKG_BUILD_DIR:=$(BUILD_DIR)/gst-rtsp-server-$(PKG_VERSION) + +PKG_MAINTAINER:=Daniel Golle +PKG_LICENSE:=LGPL-2.0-or-later +PKG_LICENSE_FILES:=COPYING COPYING.LIB + +GST_VERSION:=1.0 + +include $(INCLUDE_DIR)/package.mk +include $(INCLUDE_DIR)/nls.mk +include $(INCLUDE_DIR)/meson.mk + +define Package/gst1-rtsp-server/Default + SECTION:=multimedia + CATEGORY:=Multimedia + URL:=https://gstreamer.freedesktop.org/modules/gst-rtsp-server.html +endef + +define Package/libgst1rtspserver + $(call Package/gst1-rtsp-server/Default) + TITLE:=GStreamer RTSP server library + DEPENDS:=+libgstreamer1 +glib2 +libgst1app +libgst1net +libgst1rtp \ + +libgst1rtsp +libgst1sdp +libgst1video +endef + +define Package/libgst1rtspserver/description + A library to build RTSP servers on top of GStreamer pipelines. +endef + +define Package/gst1-mod-rtspclientsink + $(call Package/gst1-rtsp-server/Default) + TITLE:=GStreamer RTSP client sink (rtspclientsink) + DEPENDS:=+libgstreamer1 +libgst1net +libgst1rtp +libgst1rtsp +libgst1sdp \ + +libgst1rtspserver +gst1-mod-rtpmanager +gst1-mod-rtsp +endef + +define Package/gst1-mod-rtspclientsink/description + The rtspclientsink element, which pushes a stream to an RTSP server. +endef + +define Package/gst1-rtsp-launch + $(call Package/gst1-rtsp-server/Default) + TITLE:=Serve a GStreamer pipeline over RTSP + DEPENDS:=+libgst1rtspserver +gstreamer1-plugins-base +gst1-mod-rtp \ + +gst1-mod-rtpmanager +gst1-mod-unixfd +gst1-mod-videoparsersbad \ + +ucode +ucode-mod-fs +ucode-mod-uci +ucode-mod-ubus +ucode-mod-uloop \ + +ucode-mod-socket \ + +gst1-device-monitor +gstreamer1-utils +v4l-utils +endef + +define Package/gst1-rtsp-launch/description + The test-launch example of gst-rtsp-server. It takes a gst-launch style + pipeline ending in a payloader named pay0 and serves it at + rtsp://:8554/test. Includes a procd service configured through UCI, + using video topology discovery and hardware encoding pipelines. +endef + +define Package/gst1-rtsp-launch/conffiles +/etc/config/gst-rtsp +endef + +MESON_ARGS += \ + -Dexamples=enabled \ + -Dtests=disabled \ + -Ddoc=disabled \ + -Dintrospection=disabled \ + -Drtspclientsink=enabled \ + -Dglib_debug=disabled \ + -Dglib_assert=false \ + -Dglib_checks=false + +define Build/InstallDev + $(INSTALL_DIR) $(1)/usr/include/gstreamer-$(GST_VERSION) + $(CP) $(PKG_INSTALL_DIR)/usr/include/gstreamer-$(GST_VERSION)/gst \ + $(1)/usr/include/gstreamer-$(GST_VERSION)/ + $(INSTALL_DIR) $(1)/usr/lib + $(CP) $(PKG_INSTALL_DIR)/usr/lib/libgstrtspserver-$(GST_VERSION).so* \ + $(1)/usr/lib/ + $(INSTALL_DIR) $(1)/usr/lib/pkgconfig + $(CP) $(PKG_INSTALL_DIR)/usr/lib/pkgconfig/gstreamer-rtsp-server-$(GST_VERSION).pc \ + $(1)/usr/lib/pkgconfig/ +endef + +define Package/libgst1rtspserver/install + $(INSTALL_DIR) $(1)/usr/lib + $(CP) $(PKG_INSTALL_DIR)/usr/lib/libgstrtspserver-$(GST_VERSION).so.* \ + $(1)/usr/lib/ +endef + +define Package/gst1-mod-rtspclientsink/install + $(INSTALL_DIR) $(1)/usr/lib/gstreamer-$(GST_VERSION) + $(CP) $(PKG_INSTALL_DIR)/usr/lib/gstreamer-$(GST_VERSION)/libgstrtspclientsink.so \ + $(1)/usr/lib/gstreamer-$(GST_VERSION)/ +endef + +define Package/gst1-rtsp-launch/install + $(INSTALL_DIR) $(1)/usr/bin $(1)/etc/init.d $(1)/etc/config + $(INSTALL_BIN) $(MESON_BUILD_DIR)/examples/test-launch \ + $(1)/usr/bin/gst-rtsp-launch + $(INSTALL_BIN) ./files/gst-rtsp.init $(1)/etc/init.d/gst-rtsp + $(INSTALL_CONF) ./files/gst-rtsp.config $(1)/etc/config/gst-rtsp + $(INSTALL_DIR) $(1)/usr/share/gst-rtsp \ + $(1)/etc/hotplug.d/video4linux $(1)/etc/hotplug.d/media + $(INSTALL_DATA) ./files/*.uc $(1)/usr/share/gst-rtsp/ + $(INSTALL_DATA) ./files/gst-rtsp.hotplug $(1)/etc/hotplug.d/video4linux/50-gst-rtsp + $(INSTALL_DATA) ./files/gst-rtsp.hotplug $(1)/etc/hotplug.d/media/50-gst-rtsp +endef + +$(eval $(call BuildPackage,libgst1rtspserver)) +$(eval $(call BuildPackage,gst1-mod-rtspclientsink)) +$(eval $(call BuildPackage,gst1-rtsp-launch)) diff --git a/multimedia/gst1-rtsp-server/README b/multimedia/gst1-rtsp-server/README new file mode 100644 index 00000000000000..e519b507ce44c2 --- /dev/null +++ b/multimedia/gst1-rtsp-server/README @@ -0,0 +1,261 @@ +gst1-rtsp-launch installs the gst-rtsp procd service and /etc/config/gst-rtsp. +Install capture, ISP, processing and encoder plugins appropriate to the hardware. + +Discovery + +The discovery instance handles video4linux and media hotplug events. A scan +starts after 15 seconds without another event. An event during a scan makes +its result stale. Coldplug uses the same delay. + +media-ctl describes sensor, ISP, capture and metadata relationships; +v4l2-ctl describes capabilities, formats and controls. gst-device-monitor-1.0 +identifies libcamera cameras. gst-inspect-1.0 associates V4L2 encoder and +converter factories with devices. Metadata, ISP and encoder nodes are not +treated as standalone cameras. Media-controller capture uses libcamera's +pipeline handler and IPA to configure the graph. + +video-device sections retain topological paths, entity names, roles, +upstream references and handled=1. Firmware-node paths identify libcamera +sensors. USB paths omit the enumerated bus number. Device nodes, controls +and dynamic factory names stay in /var/run/gst-rtsp/devices.json, tagged with +the boot ID. Ambiguous identities fail discovery. + +New device sections use identifiers such as device0. These identifiers +are persistent UCI references; each section's path identifies its topology. +Existing section identifiers remain valid across discovery and upgrades. + +Discovery adds missing sections and relationships, preserving existing +values, offline devices and user-deleted streams. camera.configured records +that defaults have already been generated. A private UCI delta directory +separates discovery commits from pending user edits. New inputs and streams +are disabled until their capture and encoding paths have been selected. + +Hardware paths and restart behaviour + +camera sections describe physical cameras and camera-wide controls. input +sections identify capture inputs. stream sections reference an input and +describe RTSP outputs. One capture procd instance per camera contains all +its enabled streams' capture, processing and encoding branches. + +For libcamera, streams with identical raw settings and processing share a +source pad through a tee. Distinct settings request separate source pads; +their size, format and frame rate constrain the hardware outputs directly. +A V4L2 input is captured +once and branches through a tee; different output sizes require independent +hardware converters. Selected converters use DMA import/export with their +passthrough mode disabled. Hardware encoders use DMA import. + +Raw buffers stay inside the camera pipeline. No raw-frame socket bridge, +encodebin or automatic software conversion is inserted. Automatic encoder +selection chooses a discovered hardware encoder supporting the requested +codec. An unsupported path fails rather than selecting a software encoder. +Explicit encoder factories and complete pipeline overrides are expert +settings; selecting a software factory explicitly uses that implementation. + +Encoded frames pass through unixfdsink and unixfdsrc to each stream's RTSP +process. Abstract Unix stream sockets carry buffer descriptors, caps and +timestamps. Each RTSP instance parses and packetises its encoded stream. +FD-backed buffers are shared; other encoded buffers are copied into shared +memory by unixfdsink. Raw buffers remain inside the camera pipeline. +Separate server instances retain separate RTSP ports and mounts. + +Changes to this configuration restart all capture and RTSP instances. +Independent reconfiguration is deferred in favour of keeping acquisition +and encoding together. + +DMA capability, format, stride and buffer allocation must agree throughout +the raw path. A pipeline description alone cannot establish end-to-end +copy-free behaviour for an untested driver. Verify allocation and streaming +on the target hardware. The model uses capabilities and topology rather +than SoC names; hardware stream-count and resource limits still apply. + +An ISP or post-processor with several capture outputs belongs to one camera +and shares upstream resources. These outputs differ from memory-to-memory +converters, which are explicitly selected through processor references. +Multiple libcamera pads require support in the camera's pipeline handler. +Adding kernel capture nodes alone does not expose those outputs through +libcamera. The UCI camera/input/stream relationships retain multiple-output +requests while the corresponding driver and pipeline support develops. + +UCI options + +camera: + path Persistent identity, generated by discovery. + source Capture factory, libcamerasrc or v4l2src. + id Libcamera camera ID. + capture video-device section for a standalone V4L2 camera. + exposure Libcamera exposure time in microseconds, or auto. + gain Libcamera analogue gain, or auto. + brightness Libcamera brightness, or auto for the backend default. + contrast Libcamera contrast, or auto for the backend default. + saturation Libcamera chroma saturation, or auto for backend default. + sharpness Libcamera sharpness, or auto for the backend default. + source_properties Additional upstream source properties, gst-launch syntax. + +Named controls require libcamera. Exposure and gain select their individual +automatic/manual modes. Other auto values leave backend defaults in effect; +they do not introduce automatic algorithms. Support and ranges depend on +the camera. V4L2-specific extra-controls can use source_properties. + +input: + camera Owning camera section. + enabled Enable this capture input, default 0. + capture Optional video-device section overriding the capture node. + width, height V4L2 acquisition size or libcamera output defaults; 0 is auto. + framerate Acquisition frame rate, e.g. 30/1; auto by default. + pixel_format Raw format, e.g. NV12; auto by default. + colourimetry Raw colourimetry; auto by default. + io_mode V4L2 capture requires dmabuf, its generated default. + processor Ordered video-device references for hardware converters. + +stream: + enabled Serve this stream, default 0. + input Capture input section. + port Unique RTSP TCP port, generated from 8554 upwards. + mount RTSP URL path, default /test. + codec auto, h264, h265 or mjpeg. Auto prefers hardware H.265, + then H.264, then MJPEG among discovered encoders. + resolution native uses the discovered sensor pixel-array size; + custom uses width and height. + width, height Output size; 0 uses input/backend defaults. + framerate Output frame rate, e.g. 30/1; auto by default. + bitrate H.264/H.265 target bits per second. 0 derives the target + from the output size and frame rate in cbr and vbr mode, + 60 bits per 1000 pixels and frame for H.265 and 120 for + H.264, and uses encoder defaults otherwise. + bitrate_mode auto, cbr, vbr or quality; requires discovered V4L2 support. + Generated streams use cbr. Quality disables frame rate + control and has no bitrate target. + quality high, medium or low for fixed-quantiser H.264/H.265. + jpeg_quality JPEG quality, 0..100, subject to driver limits; auto default. + profile H.264/H.265 profile, e.g. high/main; auto by default. + keyframe_interval Frames per GOP; 0 uses encoder defaults. + pixel_format Encoder input format, e.g. NV12; auto by default. + colourimetry Encoder input colourimetry; auto by default. + encoder Encoder factory; auto selects discovered hardware. + encoder_device video-device section selecting its runtime encoder. + processor Ordered video-device references for hardware converters. + output_io_mode Hardware encoders require dmabuf-import, the default. + repeat_headers Request V4L2 SPS/PPS insertion on IDR frames when set to 1. + pipeline Complete pipeline override, ending in a payloader named pay0. + +Bitrate and GOP options map to V4L2 controls. Hardware JPEG quality uses the +discovered compression_quality control; an unavailable mapping fails. +MJPEG uses RTP/JPEG payload type 26; H.264/H.265 use dynamic payload type 96. +MJPEG is intended mainly for an optional HTTP browser preview endpoint. +The RTSP preview uses a scaled-down H.265 stream with a lower constant bitrate. +Browser playback of RTSP through JavaScript or WASM needs a browser-compatible +transport bridge; WebRTC is another possible frontend transport. These HTTP +and browser transports remain separate from the current RTSP service. + +Example: a main stream from an existing input0: + + config stream 'main' + option enabled '1' + option input 'input0' + option port '8554' + option mount '/main' + option codec 'auto' + option resolution 'native' + option framerate 'auto' + option bitrate_mode 'cbr' + option bitrate '0' + option profile 'auto' + option pixel_format 'auto' + option repeat_headers 'auto' + +Sensor-native dimensions come from libcamera's PixelArraySize property. +Missing native dimensions fail validation. Format, frame rate and codec +profile negotiate through GStreamer. A derived bitrate assumes 30 frames per +second when the frame rate is auto. High quality reduces both driver-default +I/P quantisers by six, one halving of the H.264/H.265 quantisation step; +medium retains the defaults and low increases them by six. Values are +clamped to discovered control ranges. JPEG quality mode uses the driver's +maximum compression_quality value. Bitrate targets are rejected in quality mode. + +A scaled preview requires an independently scaled hardware output. On RV1126, +the current rkisp2 libcamera handler exposes one ISP output, so parallel sizes +require additional hardware scaler and pipeline-handler support. Both H.265 +profiles are represented through ONVIF Media2; Media1 cannot describe H.265. + +Select capture and processing paths supported by the hardware, enable input0, +then commit and reload. A reload after configuration changes interrupts streams. + + uci set gst-rtsp.input0.enabled='1' + uci commit gst-rtsp + /etc/init.d/gst-rtsp reload + +Upstream test-launch serves streams without authentication. URLs are +rtsp://:/. + +ONVIF integration + +Install onvif-simple-server for CGI services through uhttpd and a separate +procd WS-Discovery instance. Its uci-defaults script adds uhttpd.onvif with +HTTP listeners on port 8080 and document root /www/onvif. Existing uhttpd +sections and an existing uhttpd.onvif section are preserved. +The defaults script also persists a device UUID shared by CGI services and +discovery. CGI URLs use the local address of the accepted HTTP connection. +Discovery announces on every active multicast-capable interface over IPv4 +and IPv6, and follows kernel interface and address notifications. Network +changes do not restart the video pipelines. + +The gst-rtsp service generates /var/run/onvif-simple-server/config.json +from enabled streams. The capture supervisor reads negotiated caps from +gst-launch and publishes dimensions, frame rate and codec profile. ONVIF +waits for every enabled stream's caps and accepts records only from the +current boot and matching capture pipeline. Codec bitrate, GOP and JPEG +quality use explicit stream values or discovered encoder defaults. Quality +mode reports zero for its unset bitrate limit. The interframe quality value +is the inverse configured I-frame quantiser, maximum QP plus one minus QP; +it describes an encoder setting, not a measured image quality. Bitrate is represented in kilobits +per second. Values that cannot be represented cause generation to fail. + +One ONVIF instance describes one camera with all its enabled streams. +Set onvif-simple-server.main.camera to a camera section when several +cameras are enabled. Set stream.onvif to 0 to exclude a stream. Pipeline +overrides require their own ONVIF configuration because the service cannot +derive their negotiated stream parameters. + +Options in onvif-simple-server.main: + enabled Enable the ONVIF service, default 1. + uuid Persistent device identity, generated at installation. + camera Optional camera section to advertise. + manufacturer Hardware vendor; defaults to the board's compatible prefix. + model Device model, defaults to system board information. + hardware_id Hardware identifier, defaults to the system board name. + firmware_ver Firmware identity, defaults to the OpenWrt release description. + serial Hardware serial number; persistent device UUID if unavailable. + user, password Optional ONVIF credentials. + config_file JSON configuration, default generated runtime file. + +Device information comes from ubus system board. FirmwareVersion carries +the OS release identity. GetHostname reports the current kernel hostname. +The name and hardware scopes carry the hostname and board identifier, with +URI component encoding, in both GetScopes and WS-Discovery. Explicit UCI +identity options override the discovered values. A device-tree or DMI serial +number is used when available; the persistent UUID identifies boards without +a readable hardware serial. An unknown hardware vendor is reported as Unknown. + +Camera source_width, source_height, source_framerate and +source_framerate_den describe the physical sensor mode. Without explicit +source settings, onvif-simple-server derives source dimensions from the +largest configured stream. + +CGI endpoints return HTTP 503 until their configuration exists. Stopping +gst-rtsp removes its generated configuration and stops discovery. A custom +config_file is managed by its owner and is preserved by the RTSP service. +Encoder setters and imaging services remain optional through upstream's +generic external helpers; see CONTROL_HELPERS.md in the installed ONVIF +data directory. This integration advertises read-only stream settings. + +The gst1-mod-unixfd dependency provides the encoded-stream handoff and can +also serve additional consumers. The generated raw-video path remains +within the capture pipeline. + +References: + https://gstreamer.freedesktop.org/documentation/unixfd/unixfdsink.html + https://gstreamer.freedesktop.org/documentation/unixfd/unixfdsrc.html + https://gstreamer.freedesktop.org/documentation/video4linux2/v4l2src.html + https://gstreamer.freedesktop.org/documentation/additional/design/dmabuf.html + https://docs.libcamera.org/getting-started.html#using-gstreamer-plugin diff --git a/multimedia/gst1-rtsp-server/files/capture.uc b/multimedia/gst1-rtsp-server/files/capture.uc new file mode 100644 index 00000000000000..cdcf3bbc47d1c0 --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/capture.uc @@ -0,0 +1,77 @@ +#!/usr/bin/ucode +import { readfile, writefile, rename, unlink, dup2, access } from 'fs'; +import * as socket from 'socket'; +import * as uloop from 'uloop'; +import { launch_argv, stream_caps_parse } from './model.uc'; + +let camera = ARGV[0]; +if (!camera || !match(camera, /^[a-zA-Z0-9_]+$/)) + die('Invalid camera section\n'); +let state = json(readfile('/var/run/gst-rtsp/devices.json')); +let boot_id = trim(readfile('/proc/sys/kernel/random/boot_id')); +if (state.boot_id != boot_id) + die('Video inventory belongs to another boot\n'); +let pipeline = ARGV[1]; +let command = launch_argv(pipeline); +if (!length(command)) + exit(0); +command[1] = '-v'; +let path = '/var/run/gst-rtsp/' + camera + '.json'; +let streams = {}, pending = '', status = 1, child, reader; +let descriptors = socket.pair(); +if (!descriptors || !uloop.init()) + die('Cannot initialise capture supervisor\n'); + +function metadata_refresh() { + if (access('/etc/init.d/onvif-simple-server', 'x')) + system('/usr/bin/ucode /usr/share/gst-rtsp/onvif.uc && /etc/init.d/onvif-simple-server reload'); +} + +function metadata_update(line) { + let caps = stream_caps_parse(line); + if (!caps) + return; + if (sprintf('%J', streams[caps.name]) == sprintf('%J', caps)) + return; + streams[caps.name] = caps; + warn('Negotiated stream: ', caps, '\n'); + let text = sprintf('%J', { boot_id, pipeline, streams }); + if (writefile(path + '.new', text) != length(text) || !rename(path + '.new', path)) + die('Cannot publish negotiated stream properties\n'); + metadata_refresh(); +} + +unlink(path); +child = uloop.process(command[0], slice(command, 1), null, function(code) { + status = code; + uloop.end(); +}, function() { + descriptors[0].close(); + dup2(descriptors[1].fileno(), 1); + descriptors[1].close(); +}); +if (!child) + die('Cannot start capture process\n'); +uloop.guard(function(e) { + warn('Capture supervisor: ', e, '\n'); + system('kill -TERM ' + child.pid()); +}); +descriptors[1].close(); +reader = uloop.handle(descriptors[0], function() { + let chunk = descriptors[0].recv(); + if (chunk == null || !length(chunk)) { + reader.delete(); + return; + } + let lines = split(pending + chunk, '\n'); + pending = pop(lines); + for (let line in lines) + metadata_update(line); +}, uloop.ULOOP_READ); +let terminate = uloop.signal('SIGTERM', () => system('kill -TERM ' + child.pid())); +let interrupt = uloop.signal('SIGINT', () => system('kill -INT ' + child.pid())); +uloop.run(); +unlink(path); +metadata_refresh(); +descriptors[0].close(); +exit(status); diff --git a/multimedia/gst1-rtsp-server/files/config.uc b/multimedia/gst1-rtsp-server/files/config.uc new file mode 100644 index 00000000000000..2947e0c297c38e --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/config.uc @@ -0,0 +1,207 @@ +import * as uci from 'uci'; +import { pipeline_build, source_build, raw_caps_build, processors_build, + encoding_build, sender_build, receiver_build, stream_resolve } from './model.uc'; + +function section_find(config, type, key) { + return filter(values(config), s => s['.type'] == type && s.path == key)[0]; +} + +function section_add(cursor, config, prefix, type, options) { + let index = 0; + while (config[prefix + index]) + index++; + let name = prefix + index; + if (!cursor.set('gst-rtsp', name, type)) + die('Cannot add configuration section\n'); + config[name] = { '.name': name, '.type': type, ...options }; + for (let option, value in options) + if (!cursor.set('gst-rtsp', name, option, value)) + die(`Cannot set ${name}.${option}\n`); + return config[name]; +} + +function links_add(cursor, section, option, values) { + let existing = section[option] ?? []; + if (type(existing) != 'array') + existing = [existing]; + for (let value in values) { + if (index(existing, value) >= 0) + continue; + if (!cursor.list_append('gst-rtsp', section['.name'], option, value)) + die(`Cannot add ${section['.name']}.${option}\n`); + push(existing, value); + } + section[option] = existing; +} + +export function config_update(cursor, inventory) { + if (!cursor.load('gst-rtsp')) + die('Cannot load gst-rtsp configuration\n'); + let config = cursor.get_all('gst-rtsp'), devices = {}; + for (let key, device in inventory.devices) { + let section = section_find(config, 'video-device', key); + if (!section) + section = section_add(cursor, config, 'device', 'video-device', { + path: key, entity: device.entity, role: device.role, handled: '1' }); + devices[key] = section; + links_add(cursor, section, 'capture_format', device.capture ?? []); + links_add(cursor, section, 'output_format', device.output ?? []); + } + for (let key, device in inventory.devices) { + let upstream = map(device.upstream, k => devices[k]?.['.name']); + links_add(cursor, devices[key], 'upstream', filter(upstream, s => s != null)); + } + for (let camera in inventory.cameras) { + let section = section_find(config, 'camera', camera.key); + if (!section) { + let options = { path: camera.key, source: camera.source }; + for (let role in ['capture', 'sensor']) + if (camera[role]) + options[role] = devices[camera[role]]['.name']; + section = section_add(cursor, config, 'camera', 'camera', options); + } + links_add(cursor, section, 'devices', map(camera.devices, k => devices[k]['.name'])); + if (camera.id && section.id == null) { + if (!cursor.set('gst-rtsp', section['.name'], 'id', camera.id)) + die('Cannot store camera ID\n'); + } + if (section.configured != null) + continue; + let used = map(filter(values(config), s => s['.type'] == 'stream'), s => int(s.port)); + let port = 8554; + while (index(used, port) >= 0 && port <= 65535) + port++; + if (port > 65535) + die('No unused stream port\n'); + let input = section_add(cursor, config, 'input', 'input', { + camera: section['.name'], enabled: '0', width: '0', height: '0', + framerate: 'auto', pixel_format: 'auto', + io_mode: camera.source == 'v4l2src' ? 'dmabuf' : 'auto' }); + section_add(cursor, config, 'stream', 'stream', { + input: input['.name'], enabled: '0', port: '' + port, mount: '/test', + codec: 'auto', resolution: 'native', framerate: 'auto', bitrate: '0', + bitrate_mode: 'cbr', quality: 'high', jpeg_quality: 'auto', + profile: 'auto', keyframe_interval: '0', pixel_format: 'auto', + colourimetry: 'auto', output_io_mode: 'dmabuf-import', + repeat_headers: 'auto', encoder: 'auto' }); + if (!cursor.set('gst-rtsp', section['.name'], 'configured', '1')) + die('Cannot mark camera as configured\n'); + section.configured = '1'; + } + let changed = length(cursor.changes('gst-rtsp')['gst-rtsp'] ?? []) > 0; + if (changed && !cursor.commit('gst-rtsp')) + die('Cannot commit gst-rtsp configuration\n'); + return changed; +}; + +export function devices_resolve(cursor, options) { + for (let name in ['capture', 'encoder_device']) + if (options[name]) + options[name] = cursor.get('gst-rtsp', options[name], 'path') ?? + die(`Unknown ${name} device\n`); + if (options.processor) { + let processors = type(options.processor) == 'array' ? + options.processor : [options.processor]; + options.processor = map(processors, p => cursor.get('gst-rtsp', p, 'path') ?? + die('Unknown processor device\n')); + } + return options; +}; + +function stream_get(cursor, section) { + let stream = cursor.get_all('gst-rtsp', section); + if (!stream || stream['.type'] != 'stream') + die('Unknown stream\n'); + let input = stream.input ? cursor.get_all('gst-rtsp', stream.input) : null; + if (stream.input && (!input || input['.type'] != 'input')) + die('Unknown capture input\n'); + let camera_name = input?.camera ?? stream.camera; + let camera = camera_name ? cursor.get_all('gst-rtsp', camera_name) : null; + if (camera_name && (!camera || camera['.type'] != 'camera')) + die('Unknown camera\n'); + if (camera) + devices_resolve(cursor, camera); + devices_resolve(cursor, stream); + return { stream, camera, input }; +} + +export function stream_pipeline(cursor, section, state) { + let entry = stream_get(cursor, section); + if (!entry.input || entry.stream.pipeline) + return pipeline_build(entry.stream, entry.camera, state); + if (entry.input.enabled != '1') + die('Capture input is disabled\n'); + return receiver_build(stream_resolve(entry.stream, entry.input, entry.camera, state)); +}; + +export function camera_pipeline(cursor, section, state) { + let camera = cursor.get_all('gst-rtsp', section); + if (!camera || camera['.type'] != 'camera') + die('Unknown camera\n'); + /* + * A raw pipeline override: the camera section carries the whole + * capture pipeline (source, branches, the unixfdsink senders). + * The stream sections then carry only their receivers, e.g. to + * mix sources, insert software elements, or scale, which the + * automatic build cannot express. + */ + if (camera.pipeline) + return camera.pipeline; + devices_resolve(cursor, camera); + let streams = []; + cursor.foreach('gst-rtsp', 'stream', function(stream) { + if (stream.enabled != '1' || stream.pipeline || !stream.input) + return; + if (cursor.get('gst-rtsp', stream.input, 'camera') == section) + push(streams, stream['.name']); + }); + if (!length(streams)) + return ''; + let pipeline = [], inputs = {}, pad = 0; + if (camera.source == 'libcamerasrc') + push(pipeline, source_build({}, camera, state) + ' name=camera'); + for (let name in streams) { + let entry = stream_get(cursor, name); + if (entry.input.enabled != '1') + die('Capture input is disabled\n'); + if (entry.stream.source || entry.stream.capture || entry.stream.source_properties) + die('Stream source settings belong to its capture input\n'); + let input = devices_resolve(cursor, { ...entry.input }); + let stream = stream_resolve(entry.stream, input, camera, state); + let source; + if (camera.source == 'libcamerasrc') { + if (input.source || input.capture || input.source_properties) + die('Libcamera owns capture and ISP output selection\n'); + for (let key in ['width', 'height', 'framerate', 'pixel_format', 'colourimetry']) + if (!stream[key] || stream[key] == '0' || stream[key] == 'auto') + stream[key] = input[key]; + let processing = processors_build(input, state) + processors_build(stream, state); + let caps = raw_caps_build(stream); + let key = processing + ' ! ' + caps; + if (!inputs[key]) { + let id = 'capture_' + pad; + let output = pad == 0 ? 'camera.src' : 'camera.src_' + (pad - 1); + push(pipeline, output + processing + ' ! ' + caps + ' ! tee name=' + id); + inputs[key] = id; + pad++; + } + source = inputs[key] + '.'; + } else { + let id = entry.stream.input; + if (!match(id, /^[a-zA-Z0-9_]+$/)) + die('Invalid input section name\n'); + if (!inputs[id]) { + if (camera.source == 'v4l2src' && input.io_mode != 'dmabuf') + die('V4L2 capture requires DMABUF export\n'); + push(pipeline, source_build(input, camera, state) + ' ! ' + + raw_caps_build(input) + ' ! tee name=' + id); + inputs[id] = true; + } + source = id + '.'; + source += processors_build(stream, state); + } + push(pipeline, source + ' ! ' + encoding_build(stream, state) + ' ! ' + + sender_build(stream)); + } + return join(' ', pipeline); +}; diff --git a/multimedia/gst1-rtsp-server/files/discover.uc b/multimedia/gst1-rtsp-server/files/discover.uc new file mode 100644 index 00000000000000..6961d26b8a5fc4 --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/discover.uc @@ -0,0 +1,50 @@ +#!/usr/bin/ucode +import { mkdir, readfile, writefile, rename, glob, realpath } from 'fs'; +import * as uci from 'uci'; +import * as ubus from 'ubus'; +import * as uloop from 'uloop'; +import { command_read, inventory_read, MEDIA_SYSFS } from './model.uc'; +import { config_update } from './config.uc'; +import { watcher_create } from './watch.uc'; + +const STATE_DIR = '/var/run/gst-rtsp'; + +function topology_stamp() { + return join('\n', map(sort([ + ...glob('/sys/class/video4linux/*'), ...glob(`/sys/${MEDIA_SYSFS}/media*`) + ]), p => p + '=' + realpath(p))); +} + +function inventory_apply(inventory, connection) { + let cursor = uci.cursor('/etc/config', STATE_DIR + '/uci', ''); + let text = sprintf('%J', { boot_id: trim(readfile('/proc/sys/kernel/random/boot_id')), + ...inventory }); + if (writefile(STATE_DIR + '/devices.json.new', text) != length(text) || + !rename(STATE_DIR + '/devices.json.new', STATE_DIR + '/devices.json')) + die('Cannot store runtime video topology\n'); + config_update(cursor, inventory); + connection.call({ object: 'service', method: 'event', + data: { type: 'config.change', data: { package: 'gst-rtsp' } } }); + if (connection.error()) + die('Cannot notify procd of video configuration\n'); +} + +mkdir(STATE_DIR, 0755); +mkdir(STATE_DIR + '/uci', 0700); +uloop.init(); +let connection = ubus.connect(); +if (!connection) + die('Cannot connect to ubus\n'); +let watcher = watcher_create(() => inventory_read('/sys', command_read), + inventory => inventory_apply(inventory, connection), topology_stamp); +let object = connection.publish('gst-rtsp', { + discover: { call: function() { watcher.event(); return {}; } } +}); +if (!object) + die('Cannot publish gst-rtsp discovery object\n'); +uloop.signal('SIGTERM', function() { + watcher.stop(); + uloop.end(); +}); +watcher.event(); +uloop.run(); diff --git a/multimedia/gst1-rtsp-server/files/gst-rtsp.config b/multimedia/gst1-rtsp-server/files/gst-rtsp.config new file mode 100644 index 00000000000000..1b57c1852c2357 --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/gst-rtsp.config @@ -0,0 +1 @@ +config service 'main' diff --git a/multimedia/gst1-rtsp-server/files/gst-rtsp.hotplug b/multimedia/gst1-rtsp-server/files/gst-rtsp.hotplug new file mode 100644 index 00000000000000..2371cbabce325e --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/gst-rtsp.hotplug @@ -0,0 +1,5 @@ +#!/bin/sh + +case "$ACTION" in + add|remove|change) ubus call gst-rtsp discover '{}' >/dev/null 2>&1 ;; +esac diff --git a/multimedia/gst1-rtsp-server/files/gst-rtsp.init b/multimedia/gst1-rtsp-server/files/gst-rtsp.init new file mode 100644 index 00000000000000..6c2c4fc0434bf0 --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/gst-rtsp.init @@ -0,0 +1,89 @@ +#!/bin/sh /etc/rc.common + +START=99 +USE_PROCD=1 + +camera_start() { + local section="$1" + local pipeline namespace keys key argument + + pipeline="$(/usr/bin/ucode /usr/share/gst-rtsp/pipeline.uc camera-argv "$section")" || return 1 + json_set_namespace gst_rtsp_capture_ namespace + if ! json_load "$pipeline" || ! json_select command; then + json_cleanup + json_set_namespace "$namespace" + return 1 + fi + json_get_keys keys + set -- + for key in $keys; do + json_get_var argument "$key" + set -- "$@" "$argument" + done + json_cleanup + json_set_namespace "$namespace" + [ "$#" -gt 0 ] || return 0 + procd_open_instance "capture_$section" + procd_set_param command "$@" + procd_set_param file /etc/config/gst-rtsp + procd_set_param stderr 1 + procd_set_param respawn + procd_close_instance +} + +stream_start() { + local section="$1" + local enabled port mount pipeline + + config_get_bool enabled "$section" enabled 0 + [ "$enabled" = 1 ] || return 0 + + config_get port "$section" port 8554 + config_get mount "$section" mount /test + + case "$port" in + ''|*[!0-9]*) return 1 ;; + esac + [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1 + case "$mount" in + /*) ;; + *) return 1 ;; + esac + + pipeline="$(/usr/bin/ucode /usr/share/gst-rtsp/pipeline.uc "$section")" || return 1 + + procd_open_instance "stream_$section" + procd_set_param command /usr/bin/gst-rtsp-launch -p "$port" -m "$mount" "$pipeline" + procd_set_param file /etc/config/gst-rtsp + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param respawn + procd_close_instance +} + +start_service() { + procd_open_instance discovery + procd_set_param command /usr/bin/ucode /usr/share/gst-rtsp/discover.uc + procd_set_param stderr 1 + procd_set_param respawn + procd_close_instance + config_load gst-rtsp + config_foreach camera_start camera + config_foreach stream_start stream +} + +service_started() { + [ -x /etc/init.d/onvif-simple-server ] || return 0 + /usr/bin/ucode /usr/share/gst-rtsp/onvif.uc + /etc/init.d/onvif-simple-server reload +} + +service_stopped() { + [ -x /etc/init.d/onvif-simple-server ] || return 0 + /usr/bin/ucode /usr/share/gst-rtsp/onvif.uc stop + /etc/init.d/onvif-simple-server stop +} + +service_triggers() { + procd_add_reload_trigger gst-rtsp onvif-simple-server uhttpd +} diff --git a/multimedia/gst1-rtsp-server/files/model.uc b/multimedia/gst1-rtsp-server/files/model.uc new file mode 100644 index 00000000000000..f30d03526c1e0c --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/model.uc @@ -0,0 +1,625 @@ +import { readfile, realpath, glob, basename, dirname, popen } from 'fs'; + +export const MEDIA_SYSFS = 'bus/media/devices'; + +const V4L2_CAP_META_CAPTURE = 0x00800000; +const V4L2_CAP_VIDEO_M2M = 0x00008000; +const V4L2_CAP_VIDEO_M2M_MPLANE = 0x00004000; +const V4L2_CAP_VIDEO_CAPTURE = 0x00000001; +const V4L2_CAP_VIDEO_CAPTURE_MPLANE = 0x00001000; +const V4L2_CAP_IO_MC = 0x20000000; + +export function gst_quote(value) { + return '"' + replace('' + value, /[\\"]/g, '\\$&') + '"'; +}; + +export function launch_argv(pipeline) { + return pipeline ? ['/usr/bin/gst-launch-1.0', '-q', ...split(pipeline, ' ')] : []; +}; + +function shell_quote(value) { + return "'" + replace(value, /'/g, "'\\''") + "'"; +} + +export function command_read(args) { + let pipe = popen(join(' ', map(args, shell_quote)) + ' 2>/dev/null', 'r'); + if (!pipe) + die(`Cannot run ${args[0]}\n`); + let text = pipe.read('all'); + let status = pipe.close(); + return status == 0 ? text : null; +}; + +export function path_normalise(path) { + let parts = split(path, '/'); + return join('/', map(parts, function(part) { + if (match(part, /^usb[0-9]+$/)) + return 'usb'; + let usb = match(part, /^[0-9]+-([0-9.]+(:[0-9]+\.[0-9]+)?)$/); + return usb ? usb[1] : part; + })); +}; + +function firmware_path(device, root) { + let path = realpath(device + '/of_node'); + if (path) + return replace(path, root + '/firmware/devicetree', ''); + return trim(readfile(device + '/firmware_node/path') ?? ''); +} + +export function node_identity(root, name, entity, role) { + let device = realpath(`${root}/class/video4linux/${name}/device`); + if (!device && realpath(`${root}/class/video4linux/${name}`) == + `${root}/devices/virtual/video4linux/${name}`) + device = `${root}/devices/virtual`; + if (!device) + return null; + let firmware = firmware_path(device, root); + let path = path_normalise(replace(device, root + '/devices/', '')); + let endpoint = entity ?? trim(readfile(`${root}/class/video4linux/${name}/name`) ?? ''); + if (!endpoint) + return null; + let key = firmware && match(name, /^v4l-subdev/) ? firmware : + `${path}/${role}/${endpoint}`; + return { key, path, entity: endpoint, firmware }; +}; + +export function media_parse(text) { + let entities = [], current, pad; + for (let line in split(text ?? '', '\n')) { + let entity = match(line, /^- entity [0-9]+: (.*) \([0-9]+ pad/); + if (entity) { + current = { name: entity[1], links: [] }; + push(entities, current); + continue; + } + if (!current) + continue; + let node = match(line, /device node name (\/dev\/(video[0-9]+|v4l-subdev[0-9]+))/); + if (node) + current.node = basename(node[1]); + let subtype = match(line, /subtype ([^ ]+)/); + if (subtype) + current.role = subtype[1] == 'Sensor' ? 'sensor' : 'subdevice'; + let p = match(line, /^\s*pad([0-9]+):/); + if (p) + pad = int(p[1]); + let link = match(line, /^\s*<- "(.*)":([0-9]+) \[(.*)\]/); + if (link) + push(current.links, { entity: link[1], source_pad: int(link[2]), + sink_pad: pad, enabled: index(link[3], 'ENABLED') >= 0 }); + } + return entities; +}; + +export function formats_parse(text) { + let formats = []; + for (let line in split(text ?? '', '\n')) { + let format = match(line, /\[[0-9]+\]: '([^']+)'/); + if (format) + push(formats, format[1]); + } + return uniq(formats); +}; + +export function role_parse(text, capture, output) { + let caps = match(text ?? '', /Device Caps\s*:\s*(0x[0-9a-fA-F]+)/); + if (!caps) + caps = match(text ?? '', /Capabilities\s*:\s*(0x[0-9a-fA-F]+)/); + if (!caps) + return null; + let value = int(caps[1], 16); + if (value & V4L2_CAP_META_CAPTURE) + return 'metadata'; + if (value & (V4L2_CAP_VIDEO_M2M | V4L2_CAP_VIDEO_M2M_MPLANE)) { + let encoded = f => index(['H264', 'HEVC', 'MJPG', 'JPEG'], f) >= 0; + if (length(filter(capture, encoded)) && !length(filter(output, encoded))) + return 'encoder'; + if (length(filter(output, encoded))) + return 'decoder'; + return 'processor'; + } + if (value & (V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_VIDEO_CAPTURE_MPLANE)) + return value & V4L2_CAP_IO_MC ? 'capture_mc' : 'capture'; + return 'output'; +}; + +function graph_read(root, run, nodes) { + let graphs = []; + for (let path in sort(glob(`${root}/${MEDIA_SYSFS}/media*`))) { + let name = basename(path); + let text = run(['media-ctl', '-d', '/dev/' + name, '-p']); + if (text == null) + die(`Cannot inspect /dev/${name}\n`); + let target = realpath(path); + if (!target) + continue; + let parent = dirname(target); + let graph = path_normalise(replace(parent, root + '/devices/', '')); + let entities = media_parse(text), by_name = {}; + for (let entity in entities) { + let node = entity.node ? nodes[entity.node] : null; + if (node) { + let identity = node_identity(root, entity.node, entity.name, node.role); + node = { ...node, ...identity }; + nodes[entity.node] = node; + } + if (!node) { + let identity = entity.node ? + node_identity(root, entity.node, entity.name, entity.role) : null; + node = { ...(identity ?? { key: `${graph}/${entity.name}`, + path: graph, entity: entity.name }), role: entity.role ?? 'subdevice', + node: entity.node ? '/dev/' + entity.node : null, upstream: [], links: [] }; + if (entity.node) + nodes[entity.node] = node; + else + nodes[`${graph}/${entity.name}`] = node; + } + by_name[entity.name] = node; + entity.device = node; + } + for (let entity in entities) { + for (let link in entity.links) { + let upstream = by_name[link.entity]; + if (!upstream) + continue; + push(entity.device.upstream, upstream.key); + push(entity.device.links, { source: upstream.key, + source_pad: link.source_pad, sink_pad: link.sink_pad, + enabled: link.enabled }); + } + } + push(graphs, { path: graph, devices: uniq(map(entities, e => e.device.key)) }); + } + return graphs; +} + +export function cameras_parse(text) { + let cameras = []; + for (let block in split(text ?? '', 'Device found:')) { + if (!match(block, /gst-launch-1.0 libcamerasrc /)) + continue; + let name = match(block, /\n\s*name\s*:\s*([^\n]+)/); + if (!name) + continue; + let caps = [], active; + for (let line in split(block, '\n')) { + let first = match(line, /^\s*caps\s*:\s*(.*)/); + if (first) { + push(caps, first[1]); + active = true; + continue; + } + if (active && match(line, /^\s*(properties:|gst-launch-1.0)/)) + active = false; + if (active && trim(line)) + push(caps, trim(line)); + } + let size = match(block, /api\.libcamera\.PixelArraySize\s*=\s*<\s*\(int\)\s*([0-9]+),\s*\(int\)\s*([0-9]+)/); + push(cameras, { id: trim(name[1]), source: 'libcamerasrc', caps, + native: size ? { width: int(size[1]), height: int(size[2]) } : null }); + } + return cameras; +}; + +export function encoder_device_parse(text) { + let property; + for (let line in split(text ?? '', '\n')) { + let field = match(line, /^ ([a-zA-Z0-9_-]+) +:/); + if (field) + property = field[1]; + let value = match(line, /Default: "(\/dev\/video[0-9]+)"/); + if (property == 'device' && value) + return value[1]; + } +}; + +function factories_read(nodes, run) { + let result = { encoders: [], processors: [] }; + let text = run(['gst-inspect-1.0', '--plugin', 'video4linux2']); + for (let line in split(text ?? '', '\n')) { + let factory = match(line, /^\s*([^ :]+(h264enc|h265enc|jpegenc|convert)):/); + if (!factory) + continue; + let device = encoder_device_parse(run(['gst-inspect-1.0', factory[1]])); + let node = filter(values(nodes), n => n.node == device)[0]; + if (!node) + continue; + if (factory[2] == 'convert' && node.role == 'processor') + push(result.processors, { key: node.key, factory: factory[1] }); + else if (node.role == 'encoder' && factory[2] != 'convert') + push(result.encoders, { key: node.key, + codec: factory[2] == 'jpegenc' ? 'mjpeg' : substr(factory[2], 0, 4), + factory: factory[1] }); + } + return result; +} + +export function controls_parse(text) { + let controls = {}, current; + for (let line in split(text ?? '', '\n')) { + let field = match(line, /^\s*([a-zA-Z0-9_]+)\s+(0x[0-9a-fA-F]+)\s+\(([^)]+)\)\s*:\s*(.*)/); + if (field) { + current = { id: int(field[2], 16), type: field[3], menu: {} }; + controls[field[1]] = current; + for (let word in split(field[4], ' ')) { + let range = match(word, /^(min|max|step|default)=(-?[0-9]+)$/); + if (range) + current[range[1]] = int(range[2]); + } + continue; + } + let menu = match(line, /^\s*([0-9]+): (.*)$/); + if (menu && current) + current.menu[menu[1]] = menu[2]; + } + return controls; +}; + +export function inventory_read(root, run) { + let nodes = {}; + for (let path in sort(glob(`${root}/class/video4linux/video*`))) { + let name = basename(path); + let info = run(['v4l2-ctl', '-d', '/dev/' + name, '-D']); + if (info == null) + die(`Cannot inspect /dev/${name}\n`); + let capture = formats_parse(run(['v4l2-ctl', '-d', '/dev/' + name, '--list-formats'])); + let output = formats_parse(run(['v4l2-ctl', '-d', '/dev/' + name, '--list-formats-out'])); + let role = role_parse(info, capture, output); + let identity = role ? node_identity(root, name, null, role) : null; + if (!identity) + die(`No stable identity for /dev/${name}\n`); + let controls = controls_parse(run(['v4l2-ctl', '-d', '/dev/' + name, + '--list-ctrls-menus'])); + nodes[name] = { ...identity, role, capture, output, controls, node: '/dev/' + name, + upstream: [], links: [] }; + } + let graphs = graph_read(root, run, nodes); + let cameras = cameras_parse(run(['gst-device-monitor-1.0', '-i', 'Video/Source'])); + let result = { devices: {}, graphs, cameras: [], ...factories_read(nodes, run) }; + for (let name, node in nodes) { + if (result.devices[node.key]) + die(`Ambiguous video identity: ${node.key}\n`); + result.devices[node.key] = node; + } + for (let camera in cameras) { + let sensor = filter(values(nodes), n => n.firmware == camera.id)[0]; + if (!sensor) + continue; + let graph = filter(graphs, g => index(g.devices, sensor.key) >= 0)[0]; + push(result.cameras, { ...camera, key: camera.id, sensor: sensor.key, + devices: graph?.devices ?? [sensor.key] }); + } + for (let name, node in nodes) { + if (node.role != 'capture') + continue; + push(result.cameras, { key: node.key, capture: node.key, + source: 'v4l2src', devices: [node.key] }); + } + return result; +}; + +function number_get(options, name, maximum) { + let value = options[name]; + if (value == null || value == '' || value == 'auto') + return 0; + if (!match('' + value, /^[0-9]+$/) || int(value) > maximum) + die(`Invalid ${name}\n`); + return int(value); +} + +function camera_controls_build(camera) { + let properties = []; + for (let name, property in { exposure: 'exposure-time', gain: 'analogue-gain', + brightness: 'brightness', contrast: 'contrast', saturation: 'saturation', + sharpness: 'sharpness' }) { + let value = camera?.[name]; + if (value == null) + continue; + if (camera.source != 'libcamerasrc') + die('Named camera controls require the libcamera source\n'); + if (value != 'auto' && !match(value, /^-?[0-9]+(\.[0-9]+)?$/)) + die(`Invalid camera ${name}\n`); + if (name == 'exposure' || name == 'gain') + push(properties, `${property}-mode=${value == 'auto' ? 0 : 1}`); + if (value != 'auto') + push(properties, `${property}=${value}`); + } + return join(' ', properties); +} + +export function processors_build(stream, state) { + let pipeline = ''; + let processors = stream.processor ?? []; + if (type(processors) != 'array') + processors = [processors]; + for (let key in processors) { + let processor = filter(state.processors ?? [], p => p.key == key)[0]; + if (!processor) + die('Processor device is absent or has no GStreamer converter\n'); + pipeline += ' ! ' + processor.factory + + ' disable-passthrough=true output-io-mode=dmabuf-import capture-io-mode=dmabuf'; + } + return pipeline; +}; + +export function source_build(stream, camera, state) { + let source = stream.source ?? camera?.source ?? 'autovideosrc'; + if (!match(source, /^[a-zA-Z0-9_]+$/)) + die('Invalid source factory\n'); + let capture = stream.capture ?? camera?.capture; + let camera_id = stream.camera_id ?? camera?.id; + if (source == 'libcamerasrc' && camera_id) + source += ' camera-name=' + gst_quote(camera_id); + if (source == 'v4l2src' && capture) { + let node = state.devices?.[capture]?.node; + if (!node) + die('Capture device is absent\n'); + source += ' device=' + gst_quote(node); + if (stream.io_mode && stream.io_mode != 'auto') { + if (index(['rw', 'mmap', 'userptr', 'dmabuf', 'dmabuf-import'], stream.io_mode) < 0) + die('Invalid capture io_mode\n'); + source += ' io-mode=' + stream.io_mode; + } + } + let controls = camera_controls_build(camera); + if (controls) + source += ' ' + controls; + if (camera?.source_properties) + source += ' ' + camera.source_properties; + if (stream.source_properties) + source += ' ' + stream.source_properties; + return source + processors_build(stream, state); +}; + +export function raw_caps_build(stream) { + let raw = 'video/x-raw'; + for (let name in ['width', 'height']) { + let value = number_get(stream, name, 2147483647); + if (value) + raw += `,${name}=${value}`; + } + if (stream.framerate && stream.framerate != 'auto') { + if (!match(stream.framerate, /^[1-9][0-9]*(\/[1-9][0-9]*)?$/)) + die('Invalid framerate\n'); + raw += ',framerate=' + stream.framerate + + (index(stream.framerate, '/') < 0 ? '/1' : ''); + } + for (let key, name in { pixel_format: 'format', colourimetry: 'colorimetry' }) { + if (!stream[key] || stream[key] == 'auto') + continue; + if (!match(stream[key], /^[a-zA-Z0-9_-]+$/)) + die(`Invalid ${key}\n`); + raw += `,${name}=${stream[key]}`; + } + return raw; +}; + +export function encoder_select(stream, state) { + let codec = stream.codec ?? 'h264'; + if (codec == 'auto') { + for (let candidate in ['h265', 'h264', 'mjpeg']) { + let available = filter(state.encoders ?? [], e => e.codec == candidate && + (!stream.encoder_device || e.key == stream.encoder_device) && + (!stream.encoder || stream.encoder == 'auto' || e.factory == stream.encoder)); + if (length(available)) { + codec = candidate; + break; + } + } + } + if (index(['h264', 'h265', 'mjpeg'], codec) < 0) + die('Invalid codec\n'); + let candidates = filter(state.encoders ?? [], e => e.codec == codec); + let encoder = stream.encoder ?? 'auto'; + let selected = stream.encoder_device ? + filter(candidates, e => e.key == stream.encoder_device)[0] : + (encoder == 'auto' ? candidates[0] : filter(candidates, e => e.factory == encoder)[0]); + if (encoder == 'auto' || stream.encoder_device) { + if (!selected) + die('No hardware encoder supports the requested codec\n'); + encoder = selected.factory; + } + if (!match(encoder, /^[a-zA-Z0-9_]+$/)) + die('Invalid encoder factory\n'); + return { ...(selected ?? {}), codec, factory: encoder }; +}; + +export function stream_resolve(stream, input, camera, state) { + stream = { ...stream }; + for (let key in ['width', 'height', 'framerate', 'pixel_format', 'colourimetry']) + if (!stream[key] || stream[key] == '0' || stream[key] == 'auto') + stream[key] = input?.[key]; + if (stream.resolution == 'native') { + let device = filter(state.cameras ?? [], c => c.key == camera?.path)[0]; + if (!device?.native?.width || !device.native.height) + die('Sensor-native resolution is unavailable\n'); + stream.width = device.native.width; + stream.height = device.native.height; + } + stream.codec = encoder_select(stream, state).codec; + return stream; +}; + +export function quantisers_get(stream, controls) { + let prefix = stream.codec == 'h265' ? 'hevc' : 'h264'; + let result = {}; + let offset = { high: -6, medium: 0, low: 6 }[stream.quality ?? 'high']; + if (offset == null) + die('Invalid encoder quality\n'); + for (let frame in ['i', 'p']) { + let name = `${prefix}_${frame}_frame_qp_value`; + let control = controls[name]; + if (control?.default == null || control.min == null || control.max == null) + die('Hardware quantiser controls are unavailable\n'); + result[name] = max(control.min, min(control.max, control.default + offset)); + } + return result; +}; + +/* 15 Mbit/s for 3856x2192 at 30 fps in H.265; H.264 needs about twice */ +const BITS_PER_KILOPIXEL = { h265: 60, h264: 120 }; +/* The sensor modes in use run at 30 fps when no rate is configured */ +const DEFAULT_FRAMERATE = 30; + +export function bitrate_default(stream, codec) { + let width = number_get(stream, 'width', 2147483647); + let height = number_get(stream, 'height', 2147483647); + if (!width || !height || !BITS_PER_KILOPIXEL[codec]) + return 0; + let num = DEFAULT_FRAMERATE, den = 1; + let framerate = match('' + (stream.framerate ?? ''), /^([1-9][0-9]*)(\/([1-9][0-9]*))?$/); + if (framerate) { + num = int(framerate[1]); + den = framerate[3] ? int(framerate[3]) : 1; + } + return int(width * height * num * BITS_PER_KILOPIXEL[codec] / (den * 1000)); +}; + +export function encoding_build(stream, state) { + let selected = encoder_select(stream, state); + let codec = selected.codec; + let encoder = selected.factory; + let bitrate = number_get(stream, 'bitrate', 2147483647); + let keyframe = number_get(stream, 'keyframe_interval', 2147483647); + let quality = stream.jpeg_quality; + let bitrate_mode = stream.bitrate_mode ?? 'auto'; + let properties = [], controls = []; + let hardware = substr(encoder, 0, 4) == 'v4l2'; + if (hardware && !selected.key) + die('Encoder factory is not associated with discovered hardware\n'); + if (codec == 'mjpeg' && (bitrate || keyframe)) + die('MJPEG uses jpeg_quality rather than bitrate or keyframe_interval\n'); + if (index(['auto', 'cbr', 'vbr', 'quality'], bitrate_mode) < 0) + die('Invalid bitrate_mode\n'); + if (bitrate_mode != 'auto' && (!hardware || + (codec == 'mjpeg' && bitrate_mode != 'quality'))) + die('Explicit bitrate mode requires a hardware H.264 or H.265 encoder\n'); + if (hardware) { + if (stream.output_io_mode && stream.output_io_mode != 'auto' && + stream.output_io_mode != 'dmabuf-import') + die('Hardware encoding requires DMABUF import\n'); + push(properties, 'output-io-mode=dmabuf-import'); + push(properties, 'capture-io-mode=mmap'); + if (bitrate_mode == 'quality' && codec != 'mjpeg') { + if (bitrate) + die('Quality mode has no bitrate target\n'); + let available = state.devices[selected.key].controls; + if (!available?.frame_level_rate_control_enable) + die('Hardware fixed-quantiser mode is unavailable\n'); + push(controls, 'frame_level_rate_control_enable=(int)0'); + for (let name, value in quantisers_get({ ...stream, codec }, available)) + push(controls, `${name}=(int)${value}`); + } + if (bitrate_mode == 'quality' && codec == 'mjpeg') { + let control = state.devices[selected.key].controls?.compression_quality; + if (control?.max == null) + die('Hardware JPEG quality control is unavailable\n'); + quality = '' + control.max; + } + if (!bitrate && (bitrate_mode == 'cbr' || bitrate_mode == 'vbr')) + bitrate = bitrate_default(stream, codec); + if (bitrate) + push(controls, `video_bitrate=(int)${bitrate}`); + if (bitrate_mode == 'cbr' || bitrate_mode == 'vbr') { + let mode = bitrate_mode == 'cbr' ? 1 : 0; + let supported = state.devices?.[selected.key]?.controls?.video_bitrate_mode; + if (supported?.menu?.['' + mode] == null) + die('Hardware encoder does not support the requested bitrate mode\n'); + push(controls, `video_bitrate_mode=(int)${mode}`); + } + if (keyframe) + push(controls, `video_gop_size=(int)${keyframe}`); + if (stream.repeat_headers == '1' || (stream.repeat_headers == 'auto' && + state.devices[selected.key].controls?.prepend_sps_and_pps_to_idr)) + push(controls, 'prepend_sps_and_pps_to_idr=(int)1'); + } else if (encoder == 'x264enc' || encoder == 'x265enc') { + if (bitrate) + push(properties, 'bitrate=' + int((bitrate + 999) / 1000)); + if (keyframe) + push(properties, 'key-int-max=' + keyframe); + } else if (encoder == 'openh264enc') { + if (bitrate) + push(properties, 'bitrate=' + bitrate); + if (keyframe) + push(properties, 'gop-size=' + keyframe); + } else if (bitrate || keyframe) { + die('Encoder bitrate and keyframe controls are unknown\n'); + } + if (quality != null && quality != '' && quality != 'auto') { + if (codec != 'mjpeg') + die('jpeg_quality requires MJPEG\n'); + if (!match('' + quality, /^[0-9]+$/) || int(quality) > 100) + die('Invalid jpeg_quality\n'); + quality = int(quality); + if (hardware) { + let device = state.devices?.[selected?.key]; + if (!device?.controls?.compression_quality) + die('Hardware JPEG quality control is unavailable\n'); + push(controls, 'compression_quality=(int)' + quality); + } else if (encoder == 'jpegenc') { + push(properties, 'quality=' + quality); + } else { + die('JPEG quality controls are unknown for the selected encoder\n'); + } + } + if (length(controls)) + push(properties, 'extra-controls=' + gst_quote('controls,' + join(',', controls) + ';')); + let raw = raw_caps_build(stream); + if (encoder == 'jpegenc' && (!stream.pixel_format || stream.pixel_format == 'auto')) + raw += ',format=I420'; + let encoded = codec == 'mjpeg' ? 'image/jpeg' : `video/x-${codec}`; + if (stream.profile && stream.profile != 'auto') { + if (codec == 'mjpeg' || !match(stream.profile, /^[a-zA-Z0-9_-]+$/)) + die('Invalid codec profile\n'); + encoded += ',profile=' + stream.profile; + } + return `${raw} ! queue ! ${encoder} ${join(' ', properties)} ! ${encoded}`; +}; + +export function payloader_build(codec, name) { + if (index(['mjpeg', 'h264', 'h265'], codec) < 0) + die('Invalid codec\n'); + name ??= 'pay0'; + return codec == 'mjpeg' ? `rtpjpegpay name=${name} pt=26` : + `${codec}parse ! rtp${codec}pay name=${name} pt=96 config-interval=1`; +}; + +export function pipeline_build(stream, camera, state) { + if (stream.pipeline) + return stream.pipeline; + stream = stream_resolve(stream, null, camera, state); + return `( ${source_build(stream, camera, state)} ! ${encoding_build(stream, state)}` + + ` ! ${payloader_build(stream.codec ?? 'h264')} )`; +}; + +function socket_properties_build(stream) { + let name = stream['.name']; + if (!name || !match(name, /^[a-zA-Z0-9_]+$/)) + die('Invalid stream section name\n'); + return 'socket-type=abstract socket-path=' + gst_quote('gst-rtsp.' + name); +} + +export function sender_build(stream) { + return 'unixfdsink ' + socket_properties_build(stream) + + ' name=stream_' + stream['.name'] + ' sync=false async=false'; +}; + +export function stream_caps_parse(line) { + let sink = match(line, /GstUnixFdSink:stream_([a-zA-Z0-9_]+)[./]GstPad:sink: caps = (video\/x-h26[45]|image\/jpeg),/); + if (!sink) + return null; + let result = { name: sink[1], codec: sink[2] == 'image/jpeg' ? 'mjpeg' : + substr(sink[2], length('video/x-')) }; + for (let field in ['width', 'height', 'framerate', 'profile']) { + let value = match(line, regexp('(^|, )' + field + '=\\([a-z]+\\)([^, ]+)')); + if (value) + result[field] = value[2]; + } + return result.width && result.height && result.framerate ? result : null; +}; + +export function receiver_build(stream) { + return '( unixfdsrc ' + socket_properties_build(stream) + ' ! ' + + payloader_build(stream.codec ?? 'h264') + ' )'; +}; diff --git a/multimedia/gst1-rtsp-server/files/onvif-model.uc b/multimedia/gst1-rtsp-server/files/onvif-model.uc new file mode 100644 index 00000000000000..d584df52e69548 --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/onvif-model.uc @@ -0,0 +1,134 @@ +import { encoder_select, stream_resolve, quantisers_get } from './model.uc'; + +function positive(value, name) { + if (!match('' + value, /^[1-9][0-9]*$/)) + die(`ONVIF requires an explicit ${name}\n`); + return int(value); +} + +function setting_get(stream, input, key) { + return stream[key] && stream[key] != 'auto' && stream[key] != '0' ? + stream[key] : input[key]; +} + +function profile_build(stream, input, state) { + let selected = encoder_select(stream, state); + let controls = state.devices?.[selected.key]?.controls ?? {}; + let codec = stream.codec ?? 'h264'; + let framerate = setting_get(stream, input, 'framerate'); + if (!match('' + framerate, /^[1-9][0-9]*(\/[1-9][0-9]*)?$/)) + die('ONVIF requires an explicit frame rate\n'); + let fps = split(framerate, '/'); + let profile = { + name: stream['.name'], + width: positive(setting_get(stream, input, 'width'), 'stream width'), + height: positive(setting_get(stream, input, 'height'), 'stream height'), + framerate: int(fps[0]), framerate_den: int(fps[1] ?? '1'), + type: { h264: 'H264', h265: 'H265', mjpeg: 'JPEG' }[codec], + url: `rtsp://%s:${stream.port ?? '8554'}${stream.mount ?? '/test'}`, + audio_encoder: 'NONE', audio_decoder: 'NONE', + bitrate: 0, quality: 0, gov_length: 1 + }; + if (codec == 'mjpeg') { + profile.quality = stream.bitrate_mode == 'quality' ? controls.compression_quality?.max : + stream.jpeg_quality && stream.jpeg_quality != 'auto' ? + int(stream.jpeg_quality) : controls.compression_quality?.default; + if (profile.quality == null) + die('ONVIF JPEG quality is unknown\n'); + return profile; + } + let bitrate = stream.bitrate_mode == 'quality' ? 0 : + (int(stream.bitrate) || controls.video_bitrate?.default); + if (bitrate == null || bitrate % 1000) + die('ONVIF bitrate must be known and representable in kbps\n'); + profile.bitrate = bitrate / 1000; + profile.gov_length = int(stream.keyframe_interval) || controls.video_gop_size?.default; + if (!profile.gov_length) + die('ONVIF GOP length is unknown\n'); + let control = codec == 'h264' ? 'h264_profile' : 'hevc_profile'; + let name = stream.profile && stream.profile != 'auto' ? stream.profile : + lc(controls[control]?.menu?.['' + controls[control]?.default] ?? ''); + profile.encoding_profile = { + baseline: 'Baseline', main: 'Main', high: 'High', extended: 'Extended', main10: 'Main10' + }[name]; + if (!profile.encoding_profile) + die('ONVIF codec profile is unknown\n'); + let qp = controls[codec == 'h264' ? 'h264_i_frame_qp_value' : 'hevc_i_frame_qp_value']; + if (qp?.default == null || qp?.max == null) + die('ONVIF quantiser quality is unknown\n'); + let quantiser = stream.bitrate_mode == 'quality' ? + quantisers_get(stream, controls)[codec == 'h264' ? + 'h264_i_frame_qp_value' : 'hevc_i_frame_qp_value'] : qp.default; + profile.quality = qp.max + 1 - quantiser; + return profile; +} + +function scope_component(value) { + let encoded = ''; + for (let offset = 0; offset < length(value); offset++) { + let byte = ord(value, offset); + let character = chr(byte); + encoded += match(character, /^[a-zA-Z0-9_.~-]$/) ? + character : sprintf('%%%02X', byte); + } + return encoded; +} + +export function onvif_build(config, state, options, board) { + board ??= {}; + let profiles = [], cameras = {}; + for (let stream in values(config)) { + if (stream['.type'] != 'stream' || stream.enabled != '1' || stream.onvif == '0') + continue; + let input = config[stream.input]; + if (!input || input.enabled != '1') + continue; + let camera = config[input.camera]; + if (!camera || (options.camera && options.camera != camera['.name'])) + continue; + if (stream.pipeline) + die('ONVIF needs generated stream settings\n'); + if (length(stream['.name']) > 46) + die('ONVIF stream token exceeds the supported length\n'); + let resolved = stream_resolve(stream, input, camera, state); + if (state.runtime != null) { + let actual = state.runtime[stream['.name']]; + if (!actual) + return null; + resolved = { ...resolved, ...actual }; + } + push(profiles, profile_build(resolved, input, state)); + cameras[camera['.name']] = camera; + } + if (!length(profiles)) + return null; + if (length(cameras) != 1) + die('Select one ONVIF camera in onvif-simple-server.main.camera\n'); + let camera = values(cameras)[0]; + let hardware = options.hardware_id ?? board.board_name ?? camera.path ?? camera['.name']; + let vendor = index(board.board_name ?? '', ',') >= 0 ? + split(board.board_name, ',')[0] : 'Unknown'; + let hostname = board.hostname ?? options.uuid; + let result = { + device_uuid: options.uuid, port: options.port, + manufacturer: options.manufacturer ?? vendor, + model: options.model ?? board.model ?? 'GStreamer camera', + firmware_ver: options.firmware_ver ?? board.release?.description ?? 'Unknown', + hardware_id: hardware, + serial_num: options.serial ?? board.serial ?? options.uuid, + scopes: [ + 'onvif://www.onvif.org/name/' + scope_component(hostname), + 'onvif://www.onvif.org/hardware/' + scope_component(hardware) + ], + adv_enable_media2: true, events_enable: 0, + profiles + }; + for (let key in ['source_width', 'source_height', 'source_framerate', 'source_framerate_den']) + if (camera[key]) + result[key] = positive(camera[key], key); + if (options.user) { + result.user = options.user; + result.password = options.password ?? die('ONVIF password is required\n'); + } + return result; +}; diff --git a/multimedia/gst1-rtsp-server/files/onvif.uc b/multimedia/gst1-rtsp-server/files/onvif.uc new file mode 100644 index 00000000000000..2cb62dc33ce914 --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/onvif.uc @@ -0,0 +1,74 @@ +#!/usr/bin/ucode +import { readfile, writefile, mkdir, rename, unlink, chmod } from 'fs'; +import * as uci from 'uci'; +import * as ubus from 'ubus'; +import { onvif_build } from './onvif-model.uc'; +import { devices_resolve, camera_pipeline } from './config.uc'; + +const DIRECTORY = '/var/run/onvif-simple-server'; +const CONFIG = DIRECTORY + '/config.json'; + +let cursor = uci.cursor(); +let server = cursor.get_all('onvif-simple-server', 'main'); +if (!server || server.enabled == '0' || (server.config_file && server.config_file != CONFIG)) + exit(0); +if (ARGV[0] == 'stop') { + unlink(CONFIG); + exit(0); +} + +try { + let inventory = readfile('/var/run/gst-rtsp/devices.json'); + if (!inventory) + die('Video inventory is unavailable\n'); + let state = json(inventory); + if (state.boot_id != trim(readfile('/proc/sys/kernel/random/boot_id'))) + die('Video inventory belongs to another boot\n'); + let listen = cursor.get('uhttpd', 'onvif', 'listen_http'); + if (type(listen) == 'array') + listen = listen[0]; + let port = int(split(listen ?? '', ':')[-1]); + if (port < 1 || port > 65535) + die('ONVIF HTTP listener is not configured\n'); + let settings = cursor.get_all('gst-rtsp'); + state.runtime = {}; + for (let camera in values(settings)) { + if (camera['.type'] != 'camera') + continue; + let text = readfile('/var/run/gst-rtsp/' + camera['.name'] + '.json'); + if (!text) + continue; + let actual = json(text); + if (actual.boot_id == state.boot_id && + actual.pipeline == camera_pipeline(cursor, camera['.name'], state)) + state.runtime = { ...state.runtime, ...actual.streams }; + } + for (let stream in values(settings)) + if (stream['.type'] == 'stream' && stream.enabled == '1') + devices_resolve(cursor, stream); + let board = ubus.call({ object: 'system', method: 'board', data: {} }); + if (!board) + die('Cannot read system board identity\n'); + for (let path in ['/sys/firmware/devicetree/base/serial-number', + '/sys/devices/virtual/dmi/id/product_serial']) { + if (board.serial) + break; + let serial = trim(readfile(path) ?? '', '\x00\t\r\n '); + if (serial) + board.serial = serial; + } + let config = onvif_build(settings, state, { ...server, port }, board); + if (!config) { + unlink(CONFIG); + exit(0); + } + mkdir(DIRECTORY, 0750); + let text = sprintf('%J', config); + if (writefile(CONFIG + '.new', text) != length(text) || + !chmod(CONFIG + '.new', 0600) || !rename(CONFIG + '.new', CONFIG)) + die('Cannot publish ONVIF configuration\n'); +} catch (e) { + unlink(CONFIG); + warn('gst-rtsp ONVIF: ', e, '\n'); + exit(1); +} diff --git a/multimedia/gst1-rtsp-server/files/pipeline.uc b/multimedia/gst1-rtsp-server/files/pipeline.uc new file mode 100644 index 00000000000000..6df268fc89508d --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/pipeline.uc @@ -0,0 +1,25 @@ +#!/usr/bin/ucode +import { readfile } from 'fs'; +import * as uci from 'uci'; +import { stream_pipeline, camera_pipeline } from './config.uc'; + +let state = {}; +let text = readfile('/var/run/gst-rtsp/devices.json'); +if (text) { + state = json(text); + if (state.boot_id != trim(readfile('/proc/sys/kernel/random/boot_id'))) + state = {}; +} +try { + if (ARGV[0] == 'camera-argv') { + let pipeline = camera_pipeline(uci.cursor(), ARGV[1], state); + print({ command: pipeline ? ['/usr/bin/ucode', + '/usr/share/gst-rtsp/capture.uc', ARGV[1], pipeline] : [] }); + exit(0); + } + print(ARGV[0] == 'camera' ? camera_pipeline(uci.cursor(), ARGV[1], state) : + stream_pipeline(uci.cursor(), ARGV[0], state)); +} catch (e) { + warn('gst-rtsp: ', e, '\n'); + exit(1); +} diff --git a/multimedia/gst1-rtsp-server/files/watch.uc b/multimedia/gst1-rtsp-server/files/watch.uc new file mode 100644 index 00000000000000..b568a77c11553a --- /dev/null +++ b/multimedia/gst1-rtsp-server/files/watch.uc @@ -0,0 +1,56 @@ +import * as uloop from 'uloop'; + +export const QUIET_MS = 15000; + +export function watcher_create(scan, apply, stamp) { + let generation = 0, pending, worker, timer; + function schedule() { + generation++; + pending = true; + timer.set(QUIET_MS); + } + function start() { + if (worker) + return; + let scan_generation = generation, topology = stamp(); + pending = false; + worker = uloop.task(function() { + try { + return { inventory: scan() }; + } catch (e) { + return { error: '' + e }; + } + }, function(result) { + worker = null; + if (scan_generation != generation) { + if (pending && timer.remaining() < 0) + timer.set(0); + return; + } + if (topology != stamp()) { + schedule(); + return; + } + if (result.error) { + warn('gst-rtsp discovery: ', result.error, '\n'); + return; + } + try { + apply(result.inventory); + } catch (e) { + warn('gst-rtsp configuration: ', e, '\n'); + } + }); + if (!worker) + die('Cannot start video discovery task\n'); + } + timer = uloop.timer(-1, start); + return { + event: schedule, + stop: function() { + timer.cancel(); + if (worker) + worker.kill(); + } + }; +}; diff --git a/multimedia/gst1-rtsp-server/patches/001-rtsp-client-do_send_data_list-heap-messages.patch b/multimedia/gst1-rtsp-server/patches/001-rtsp-client-do_send_data_list-heap-messages.patch new file mode 100644 index 00000000000000..a770ed15d0f6f1 --- /dev/null +++ b/multimedia/gst1-rtsp-server/patches/001-rtsp-client-do_send_data_list-heap-messages.patch @@ -0,0 +1,36 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Daniel Golle +Date: Sat, 17 Oct 2026 03:10:00 +0100 +Subject: [PATCH] rtsp-client: allocate the data list messages on the heap + +do_send_data_list() allocates one GstRTSPMessage per buffer of the list +on the stack. The list holds all RTP packets of a video frame, which are +several hundred for a 4K frame, and the send thread then goes through +gst_rtsp_watch_send_messages() and gst_rtsp_watch_write_serialized_messages() +which allocate their arrays on the stack as well. On systems whose +default thread stack is small, such as musl with 128 KiB, a large frame +overflows the stack and the server crashes. Use the heap. + +Link: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/work_items/5358 +Signed-off-by: Daniel Golle +--- +--- a/gst/rtsp-server/rtsp-client.c ++++ b/gst/rtsp-server/rtsp-client.c +@@ -1289,8 +1289,7 @@ do_send_data_list (GstBufferList * buffe + return FALSE; + } + +- messages = g_newa (GstRTSPMessage, n); +- memset (messages, 0, sizeof (GstRTSPMessage) * n); ++ messages = g_new0 (GstRTSPMessage, n); + for (i = 0; i < n; i++) { + GstBuffer *buffer = gst_buffer_list_get (buffer_list, i); + gst_rtsp_message_init_data (&messages[i], channel); +@@ -1312,6 +1311,7 @@ do_send_data_list (GstBufferList * buffe + for (i = 0; i < n; i++) { + gst_rtsp_message_unset (&messages[i]); + } ++ g_free (messages); + + if (!ret) { + GSource *idle_src; diff --git a/multimedia/gst1-rtsp-server/tests/model.uc b/multimedia/gst1-rtsp-server/tests/model.uc new file mode 100644 index 00000000000000..a613063b3e95d0 --- /dev/null +++ b/multimedia/gst1-rtsp-server/tests/model.uc @@ -0,0 +1,303 @@ +import { mkdtemp, mkdir, writefile, readfile, symlink, rename, glob, unlink } from 'fs'; +import * as uci from 'uci'; +import { inventory_read, pipeline_build, path_normalise, stream_resolve, + encoding_build, bitrate_default, stream_caps_parse } from '../files/model.uc'; +import { config_update, stream_pipeline, camera_pipeline } from '../files/config.uc'; + +let directory = mkdtemp('/tmp/gst-rtsp-test-XXXXXX'); +let root = directory + '/sys'; + +function directory_add(path) { + let current = ''; + for (let part in split(path, '/')) { + if (!part) + continue; + current += '/' + part; + mkdir(current, 0700); + } +} + +directory_add(root + '/class/video4linux'); +directory_add(root + '/bus/media/devices'); +directory_add(root + '/devices/platform/isp'); +directory_add(root + '/devices/platform/isp/media0'); +directory_add(root + '/devices/platform/codec'); +directory_add(root + '/devices/platform/converter'); +directory_add(root + '/devices/platform/i2c/sensor'); +directory_add(root + '/firmware/devicetree/base/camera0'); +symlink(root + '/firmware/devicetree/base/camera0', + root + '/devices/platform/i2c/sensor/of_node'); +symlink(root + '/devices/platform/isp/media0', root + '/bus/media/devices/media0'); +let endpoints = ['mainpath', 'parameters', 'statistics', 'encoder', 'converter']; +let mapping = [0, 1, 2, 3, 4]; +for (let index, name in endpoints) { + let path = root + '/class/video4linux/video' + index; + directory_add(path); + let parent = index == 4 ? 'converter' : (index == 3 ? 'codec' : 'isp'); + symlink(root + '/devices/platform/' + parent, path + '/device'); + writefile(path + '/name', name); +} +directory_add(root + '/class/video4linux/v4l-subdev0'); +symlink(root + '/devices/platform/i2c/sensor', root + '/class/video4linux/v4l-subdev0/device'); + +function run(args) { + if (args[0] == 'media-ctl') + return `- entity 1: sensor (1 pad, 1 link) + type V4L2 subdev subtype Sensor flags 0 + device node name /dev/v4l-subdev0 +- entity 2: isp (2 pads, 4 links) + type V4L2 subdev subtype Unknown flags 0 + pad0: SINK + <- "sensor":0 [ENABLED,IMMUTABLE] + <- "parameters":0 [ENABLED] +- entity 3: mainpath (1 pad, 1 link) + type Node subtype V4L flags 0 + device node name /dev/video${mapping[0]} + pad0: SINK + <- "isp":1 [ENABLED] +- entity 4: parameters (1 pad, 1 link) + type Node subtype V4L flags 0 + device node name /dev/video${mapping[1]} +- entity 5: statistics (1 pad, 1 link) + type Node subtype V4L flags 0 + device node name /dev/video${mapping[2]} + pad0: SINK + <- "isp":1 [ENABLED] +`; + if (args[0] == 'gst-device-monitor-1.0') + return `Device found: + name : /base/camera0 + class : Source/Video + caps : video/x-raw + properties: + api.libcamera.PixelArraySize = < (int)4056, (int)3040 > (GstValueArray) + gst-launch-1.0 libcamerasrc camera-name='/base/camera0' ! ... +`; + if (args[0] == 'gst-inspect-1.0') { + if (args[1] == '--plugin') + return ' v4l2h265enc: V4L2 H265 Encoder\n' + + ' v4l2h264enc: V4L2 H264 Encoder\n v4l2jpegenc: V4L2 JPEG Encoder\n' + + ' v4l2convert: V4L2 Converter\n'; + let device = args[1] == 'v4l2convert' ? mapping[4] : mapping[3]; + return ` device : Device location + flags: readable, writable + String. Default: "/dev/video${device}" +`; + } + if (args[2] == '/dev/video11') { + if (args[3] == '-D') + return 'Device Caps : 0x04000001'; + return args[3] == '--list-formats' ? "[0]: 'NV12' (Y/CbCr 4:2:0)" : ''; + } + if (index(['/dev/video20', '/dev/video21', '/dev/video22'], args[2]) >= 0) + return args[3] == '-D' ? 'Device Caps : 0x05200002' : ''; + let device = int(substr(args[2], length('/dev/video'))); + let role = index(mapping, device); + if (args[3] == '-D') + return 'Device Caps : ' + ['0x24201000', '0x04002000', '0x04800000', + '0x04004000', '0x04004000'][role]; + if (args[3] == '--list-formats-out') + return role >= 3 ? "[0]: 'NV12' (Y/CbCr 4:2:0)" : ''; + if (args[3] == '--list-ctrls-menus') + return role == 3 ? + 'compression_quality 0x009d0903 (int) : min=1 max=100 step=1 default=80 value=80\n' + + 'video_bitrate_mode 0x009909ce (menu) : min=0 max=1 default=1 value=1\n' + + ' 1: Constant Bitrate' : ''; + return role == 3 ? "[0]: 'HEVC' (HEVC)\n[1]: 'H264' (H264)\n[2]: 'MJPG' (JPEG)" : + "[0]: 'NV12' (Y/CbCr 4:2:0)"; +} + +let inventory = inventory_read(root, run); +assert(length(inventory.devices) == 7); +assert(length(inventory.cameras) == 1); +assert(length(inventory.cameras[0].devices) == 5); +assert(length(filter(values(inventory.devices), n => n.role == 'metadata')) == 1); +assert(length(filter(values(inventory.devices), n => n.role == 'encoder')) == 1); +assert(inventory.encoders[0].codec == 'h265'); +assert(inventory.processors[0].factory == 'v4l2convert'); +assert(path_normalise('pci0000/usb3/3-2/3-2.4:1.0') == 'pci0000/usb/2/2.4:1.0'); + +directory_add(directory + '/config'); +directory_add(directory + '/delta'); +writefile(directory + '/config/gst-rtsp', "config service 'main'\n"); +let cursor = uci.cursor(directory + '/config', directory + '/delta', ''); +assert(config_update(cursor, inventory)); +let config = cursor.get_all('gst-rtsp'); +assert(length(filter(values(config), s => s['.type'] == 'stream')) == 1); +assert(length(filter(values(config), s => s['.type'] == 'video-device' && s.handled == '1')) == 7); +assert(cursor.get('gst-rtsp', 'camera0', 'configured') == '1'); +assert(cursor.get('gst-rtsp', 'camera0', 'sensor') != null); +assert(cursor.get('gst-rtsp', 'input0', 'enabled') == '0'); +assert(cursor.get('gst-rtsp', 'stream0', 'bitrate_mode') == 'cbr'); +assert(cursor.get('gst-rtsp', 'stream0', 'bitrate') == '0'); +assert(index(readfile(directory + '/config/gst-rtsp'), '/dev/video') < 0); +cursor.set('gst-rtsp', 'stream0', 'width', '1920'); +cursor.set('gst-rtsp', 'stream0', 'resolution', 'custom'); +cursor.set('gst-rtsp', 'stream0', 'bitrate_mode', 'auto'); +cursor.set('gst-rtsp', 'stream0', 'bitrate', '12345678'); +cursor.commit('gst-rtsp'); +let saved = readfile(directory + '/config/gst-rtsp'); +assert(!config_update(cursor, inventory)); +assert(readfile(directory + '/config/gst-rtsp') == saved); + +for (let i in mapping) + rename(root + '/class/video4linux/video' + i, root + '/class/video4linux/tmp' + i); +mapping = [7, 4, 9, 2, 5]; +for (let i, number in mapping) + rename(root + '/class/video4linux/tmp' + i, root + '/class/video4linux/video' + number); +let renumbered = inventory_read(root, run); +assert(join('\n', sort(keys(renumbered.devices))) == join('\n', sort(keys(inventory.devices)))); +assert(!config_update(cursor, renumbered)); +assert(readfile(directory + '/config/gst-rtsp') == saved); +assert(renumbered.devices[renumbered.encoders[0].key].node == '/dev/video2'); + +let automatic = stream_resolve({ codec: 'auto', resolution: 'native' }, {}, + { path: '/base/camera0' }, renumbered); +assert(automatic.codec == 'h265' && automatic.width == 4056 && automatic.height == 3040); +assert(stream_resolve({ codec: 'auto' }, {}, {}, { + encoders: filter(renumbered.encoders, e => e.codec != 'h265') +}).codec == 'h264'); +let encoder_controls = renumbered.devices[renumbered.encoders[0].key].controls; +encoder_controls.frame_level_rate_control_enable = { type: 'bool', default: 1 }; +encoder_controls.hevc_i_frame_qp_value = { min: 1, max: 51, default: 26 }; +encoder_controls.hevc_p_frame_qp_value = { min: 1, max: 51, default: 28 }; +let quality_pipeline = encoding_build({ ...automatic, bitrate_mode: 'quality', quality: 'high' }, + renumbered); +assert(index(quality_pipeline, 'frame_level_rate_control_enable=(int)0') >= 0); +assert(index(quality_pipeline, 'hevc_i_frame_qp_value=(int)20') >= 0); +assert(index(quality_pipeline, 'hevc_p_frame_qp_value=(int)22') >= 0); +assert(index(quality_pipeline, 'video_bitrate=') < 0); +let derived_pipeline = encoding_build({ ...automatic, bitrate_mode: 'cbr', bitrate: '0' }, + renumbered); +assert(index(derived_pipeline, 'video_bitrate=(int)22194432') >= 0); +assert(index(derived_pipeline, 'video_bitrate_mode=(int)1') >= 0); +assert(bitrate_default({ width: '1280', height: '720', framerate: '25/1' }, 'h264') == 2764800); +assert(bitrate_default({ width: '1280', height: '720' }, 'mjpeg') == 0); +let caps = stream_caps_parse('/GstPipeline:pipeline0/GstUnixFdSink:stream_stream0.GstPad:sink: caps = video/x-h265, width=(int)4056, height=(int)3040, framerate=(fraction)24000/1001, profile=(string)main'); +assert(caps.name == 'stream0' && caps.width == '4056' && caps.framerate == '24000/1001'); + +cursor.set('gst-rtsp', 'input0', 'enabled', '1'); +cursor.set('gst-rtsp', 'stream0', 'enabled', '1'); +let launch = stream_pipeline(cursor, 'stream0', renumbered); +assert(index(launch, 'unixfdsrc socket-type=abstract socket-path="gst-rtsp.stream0"') >= 0); +assert(index(launch, 'h265parse ! rtph265pay name=pay0') >= 0); +let acquisition = camera_pipeline(cursor, 'camera0', renumbered); +assert(index(acquisition, 'camera-name="/base/camera0"') >= 0); +assert(index(acquisition, 'width=1920') >= 0); +assert(index(acquisition, 'video_bitrate=(int)12345678') >= 0); +assert(index(acquisition, 'v4l2h265enc output-io-mode=dmabuf-import') >= 0); +assert(index(acquisition, 'unixfdsink socket-type=abstract socket-path="gst-rtsp.stream0"') >= 0); +assert(index(acquisition, 'udpsink') < 0); +assert(index(acquisition, 'encodebin') < 0); +assert(index(acquisition, 'videoconvert') < 0); +cursor.set('gst-rtsp', 'stream0', 'bitrate_mode', 'cbr'); +assert(index(camera_pipeline(cursor, 'camera0', renumbered), + 'video_bitrate_mode=(int)1') >= 0); +cursor.set('gst-rtsp', 'stream0', 'bitrate_mode', 'vbr'); +let unsupported_mode; +try { + camera_pipeline(cursor, 'camera0', renumbered); +} catch (e) { + unsupported_mode = index('' + e, 'does not support the requested bitrate mode') >= 0; +} +assert(unsupported_mode, 'An unsupported bitrate mode must be rejected'); +cursor.delete('gst-rtsp', 'stream0', 'bitrate_mode'); +cursor.set('gst-rtsp', 'stream1', 'stream'); +for (let key, value in { input: 'input0', enabled: '1', port: '8555', codec: 'mjpeg', + width: '640', height: '480', jpeg_quality: '70' }) + cursor.set('gst-rtsp', 'stream1', key, value); +let neighbouring = stream_pipeline(cursor, 'stream1', renumbered); +let parallel = camera_pipeline(cursor, 'camera0', renumbered); +assert(index(parallel, 'camera.src_0 ! video/x-raw,width=640,height=480') >= 0); +assert(index(parallel, 'compression_quality=(int)70') >= 0); +cursor.set('gst-rtsp', 'stream2', 'stream'); +for (let key, value in { input: 'input0', enabled: '1', port: '8556', codec: 'h264', + width: '640', height: '480' }) + cursor.set('gst-rtsp', 'stream2', key, value); +let shared = camera_pipeline(cursor, 'camera0', renumbered); +assert(length(split(shared, 'tee name=capture_')) == 3); +assert(length(split(shared, 'capture_1. ! video/x-raw,width=640,height=480')) == 3); +assert(index(shared, 'camera.src_1') < 0); +cursor.delete('gst-rtsp', 'stream2'); +for (let key, value in { width: '3840', height: '2160', codec: 'h264', + profile: 'high', bitrate: '16000000', keyframe_interval: '30' }) + cursor.set('gst-rtsp', 'stream0', key, value); +assert(camera_pipeline(cursor, 'camera0', renumbered) != parallel); +assert(stream_pipeline(cursor, 'stream1', renumbered) == neighbouring); +assert(stream_pipeline(cursor, 'stream0', renumbered) != launch); +assert(index(neighbouring, 'rtpjpegpay name=pay0') >= 0); +let processor = filter(values(config), s => s.role == 'processor')[0]['.name']; +cursor.set('gst-rtsp', 'stream0', 'processor', [processor]); +assert(index(camera_pipeline(cursor, 'camera0', renumbered), + 'v4l2convert disable-passthrough=true output-io-mode=dmabuf-import') >= 0); +cursor.set('gst-rtsp', 'input0', 'processor', [processor]); +assert(index(camera_pipeline(cursor, 'camera0', renumbered), + 'camera.src ! v4l2convert disable-passthrough=true') >= 0); +cursor.delete('gst-rtsp', 'input0', 'processor'); +cursor.delete('gst-rtsp', 'stream1'); +cursor.delete('gst-rtsp', 'stream0'); +cursor.commit('gst-rtsp'); +assert(!config_update(cursor, renumbered)); +assert(cursor.get('gst-rtsp', 'stream0') == null); + +directory_add(root + '/devices/platform/xhci/usb3/3-2/3-2:1.0'); +directory_add(root + '/class/video4linux/video11'); +symlink(root + '/devices/platform/xhci/usb3/3-2/3-2:1.0', + root + '/class/video4linux/video11/device'); +writefile(root + '/class/video4linux/video11/name', 'USB camera'); +let usb = inventory_read(root, run); +assert(length(usb.cameras) == 2); +assert(config_update(cursor, usb)); +assert(cursor.get('gst-rtsp', 'camera1', 'source') == 'v4l2src'); +cursor.set('gst-rtsp', 'input1', 'enabled', '1'); +cursor.set('gst-rtsp', 'stream0', 'enabled', '1'); +cursor.set('gst-rtsp', 'stream0', 'resolution', 'custom'); +cursor.set('gst-rtsp', 'stream0', 'bitrate_mode', 'auto'); +cursor.set('gst-rtsp', 'usb_extra', 'stream'); +for (let key, value in { input: 'input1', enabled: '1', port: '8556', codec: 'h264' }) + cursor.set('gst-rtsp', 'usb_extra', key, value); +let usb_launch = camera_pipeline(cursor, 'camera1', usb); +assert(length(split(usb_launch, 'v4l2src')) == 2); +assert(index(usb_launch, 'device="/dev/video11" io-mode=dmabuf') >= 0); +assert(index(usb_launch, 'tee name=input1') >= 0); +assert(index(usb_launch, 'v4l2h265enc output-io-mode=dmabuf-import') >= 0); +assert(index(usb_launch, 'v4l2h264enc output-io-mode=dmabuf-import') >= 0); + +let failed; +try { + pipeline_build({ codec: 'h265' }, null, {}); +} catch (e) { + failed = true; +} +assert(failed, 'A missing hardware encoder must not select software'); + +function virtual_add(number) { + let path = `${root}/devices/virtual/video4linux/video${number}`; + directory_add(path); + writefile(path + '/name', 'isp-hevc'); + symlink(path, `${root}/class/video4linux/video${number}`); +} + +virtual_add(20); +let virtual = inventory_read(root, run); +let virtual_key = 'virtual/output/isp-hevc'; +assert(virtual.devices[virtual_key].node == '/dev/video20'); +assert(length(virtual.cameras) == length(usb.cameras)); +assert(config_update(cursor, virtual)); +let virtual_config = readfile(directory + '/config/gst-rtsp'); +unlink(root + '/class/video4linux/video20'); +virtual_add(21); +let virtual_renumbered = inventory_read(root, run); +assert(virtual_renumbered.devices[virtual_key].node == '/dev/video21'); +assert(!config_update(cursor, virtual_renumbered)); +assert(readfile(directory + '/config/gst-rtsp') == virtual_config); +virtual_add(22); +failed = false; +try { + inventory_read(root, run); +} catch (e) { + failed = index('' + e, 'Ambiguous video identity') >= 0; +} +assert(failed, 'Duplicate virtual card identities must be rejected'); +print('Topology, UCI preservation, renumbering, deletion and hardware pipeline checks passed\n'); +print('Fixture: ', directory, '\n'); diff --git a/multimedia/gst1-rtsp-server/tests/onvif.uc b/multimedia/gst1-rtsp-server/tests/onvif.uc new file mode 100644 index 00000000000000..6461f47c585d43 --- /dev/null +++ b/multimedia/gst1-rtsp-server/tests/onvif.uc @@ -0,0 +1,105 @@ +import { onvif_build } from '../files/onvif-model.uc'; + +function rejected(config, state, options) { + try { + onvif_build(config, state, options); + } catch (e) { + return true; + } + return false; +} + +let config = { + camera0: { '.name': 'camera0', '.type': 'camera', source_width: '3840', + source_height: '2160', source_framerate: '30', path: '/base/sensor' }, + input0: { '.name': 'input0', '.type': 'input', enabled: '1', camera: 'camera0', + width: '3840', height: '2160', framerate: '30000/1001' } +}; +let controls = { + video_bitrate: { default: 4000000 }, video_gop_size: { default: 30 }, + h264_profile: { default: 4, menu: { '4': 'High' } }, + hevc_profile: { default: 0, menu: { '0': 'Main' } }, + h264_i_frame_qp_value: { default: 26, max: 51 }, + hevc_i_frame_qp_value: { default: 26, max: 51 }, + compression_quality: { default: 50 } +}; +let state = { devices: { encoder: { controls } }, encoders: [ + { key: 'encoder', factory: 'v4l2h264enc', codec: 'h264' }, + { key: 'encoder', factory: 'v4l2h265enc', codec: 'h265' }, + { key: 'encoder', factory: 'v4l2jpegenc', codec: 'mjpeg' } +] }; +for (let codec in ['h264', 'h265', 'mjpeg']) + config[codec] = { '.name': codec, '.type': 'stream', enabled: '1', + input: 'input0', codec, port: '8554', mount: '/' + codec }; +let options = { uuid: 'b47bd8ea-7b8b-58c7-8354-88dbaddf57aa', port: 8080 }; +let result = onvif_build(config, state, options); +assert(result.device_uuid == options.uuid && !('ifs' in result)); +assert(length(result.profiles) == 3); +assert(result.source_width == 3840 && result.source_height == 2160); +assert(result.profiles[0].type == 'H264' && result.profiles[1].type == 'H265'); +assert(result.profiles[2].type == 'JPEG' && result.profiles[2].quality == 50); +assert(result.profiles[0].encoding_profile == 'High'); +assert(result.profiles[1].encoding_profile == 'Main'); +assert(result.profiles[0].bitrate == 4000 && result.profiles[0].gov_length == 30); +assert(result.profiles[0].quality == 26); +assert(result.profiles[0].framerate == 30000 && result.profiles[0].framerate_den == 1001); +let board = { + board_name: 'vendor,board /A', model: 'Vendor Camera', hostname: 'camera-lan', + serial: 'serial-001', release: { description: 'OpenWrt SNAPSHOT r123' } +}; +result = onvif_build(config, state, options, board); +assert(result.manufacturer == 'vendor' && result.model == board.model); +assert(result.firmware_ver == board.release.description); +assert(result.hardware_id == board.board_name && result.serial_num == board.serial); +assert(result.scopes[0] == 'onvif://www.onvif.org/name/camera-lan'); +assert(result.scopes[1] == 'onvif://www.onvif.org/hardware/vendor%2Cboard%20%2FA'); +result = onvif_build(config, state, { ...options, manufacturer: 'Camera Vendor', + model: 'Custom Camera', firmware_ver: 'Custom firmware', hardware_id: 'board-02', + serial: 'serial-002' }, board); +assert(result.manufacturer == 'Camera Vendor' && result.model == 'Custom Camera'); +assert(result.firmware_ver == 'Custom firmware' && result.hardware_id == 'board-02'); +assert(result.serial_num == 'serial-002'); +assert(result.scopes[1] == 'onvif://www.onvif.org/hardware/board-02'); +result = onvif_build(config, state, options, { model: 'Camera', hostname: 'cam', + board_name: 'generic_board' }); +assert(result.manufacturer == 'Unknown' && result.firmware_ver == 'Unknown'); +assert(result.serial_num == options.uuid); +board.hostname = 'cam ' + chr(0xc3, 0xa9); +result = onvif_build(config, state, options, board); +assert(result.scopes[0] == 'onvif://www.onvif.org/name/cam%20%C3%A9'); +config.h264.bitrate = '16000000'; +config.h264.profile = 'baseline'; +config.h264.keyframe_interval = '45'; +result = onvif_build(config, state, options); +assert(result.profiles[0].bitrate == 16000 && result.profiles[0].gov_length == 45); +assert(result.profiles[0].encoding_profile == 'Baseline'); +config.h264.bitrate = '16000123'; +assert(rejected(config, state, options)); +config.h264.bitrate = '16000000'; +config.input0.framerate = 'auto'; +assert(rejected(config, state, options)); +config.input0.framerate = '30/1'; +let automatic = { ...config, h264: { ...config.h264, enabled: '0' }, + mjpeg: { ...config.mjpeg, enabled: '0' }, + h265: { ...config.h265, codec: 'auto', resolution: 'native', + bitrate_mode: 'quality', quality: 'high', framerate: 'auto' } }; +let detected = { ...state, cameras: [{ key: '/base/sensor', + native: { width: 4056, height: 3040 } }], runtime: {} }; +assert(onvif_build(automatic, detected, options) == null); +controls.hevc_i_frame_qp_value.min = 1; +controls.hevc_p_frame_qp_value = { min: 1, max: 51, default: 28 }; +detected.runtime.h265 = { codec: 'h265', width: '4056', height: '3040', + framerate: '24000/1001', profile: 'main' }; +let negotiated = onvif_build(automatic, detected, options).profiles[0]; +assert(negotiated.width == 4056 && negotiated.height == 3040); +assert(negotiated.framerate == 24000 && negotiated.framerate_den == 1001); +assert(negotiated.bitrate == 0 && negotiated.quality == 32); +config.camera1 = { '.name': 'camera1', '.type': 'camera' }; +config.input1 = { ...config.input0, camera: 'camera1' }; +config.h264.input = 'input1'; +assert(rejected(config, state, options)); +assert(length(onvif_build(config, state, { ...options, camera: 'camera0' }).profiles) == 2); +config.input0.enabled = '0'; +config.input1.enabled = '0'; +assert(onvif_build(config, state, options) == null); +print('ONVIF metadata, board identity, scope encoding and source isolation passed\n'); diff --git a/multimedia/gst1-rtsp-server/tests/transport.py b/multimedia/gst1-rtsp-server/tests/transport.py new file mode 100644 index 00000000000000..ed66f30ec6fae9 --- /dev/null +++ b/multimedia/gst1-rtsp-server/tests/transport.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import subprocess + +import gi + +gi.require_version('Gst', '1.0') +gi.require_version('GstApp', '1.0') +gi.require_version('GstRtspServer', '1.0') +from gi.repository import GLib, Gst, GstRtspServer + +Gst.init(None) +TEST_TIMEOUT_SECONDS = 15 +module_path = Path(__file__).resolve().parents[1] / 'files' / 'model.uc' + + +def model_call(function, options): + script = ( + f'import {{ {function} }} from {json.dumps(str(module_path))}; ' + f'print({function}(json(ARGV[0]), {{}}, {{}}));' + ) + return subprocess.check_output( + [os.environ.get('UCODE', 'ucode'), '-e', script, json.dumps(options)], + text=True, + ) + + +def codec_check(codec, encoder, depayloader): + loop = GLib.MainLoop() + pipelines = [] + errors = [] + frames = [] + + def message_receive(bus, message): + if message.type == Gst.MessageType.ERROR: + errors.append(str(message.parse_error())) + loop.quit() + + def sample_receive(sink): + sample = sink.emit('pull-sample') + frames.append(sample.get_caps().to_string()) + if len(frames) >= 3: + GLib.idle_add(loop.quit) + return Gst.FlowReturn.OK + + def pipeline_start(launch, observer=False): + pipeline = Gst.parse_launch(launch) + if observer: + pipeline.get_by_name('observer').connect('new-sample', sample_receive) + bus = pipeline.get_bus() + bus.add_signal_watch() + bus.connect('message', message_receive) + pipelines.append(pipeline) + assert pipeline.set_state(Gst.State.PLAYING) != Gst.StateChangeReturn.FAILURE + + def timeout_fail(): + errors.append(f'{codec}: RTSP test exceeded {TEST_TIMEOUT_SECONDS} seconds') + loop.quit() + return GLib.SOURCE_REMOVE + + server = GstRtspServer.RTSPServer() + server.set_address('127.0.0.1') + server.set_service('0') + factory = GstRtspServer.RTSPMediaFactory() + stream_name = f'test_{os.getpid()}_{codec}' + factory.set_launch(model_call('receiver_build', {'.name': stream_name, 'codec': codec})) + factory.set_shared(True) + server.get_mount_points().add_factory('/test', factory) + source = server.attach(None) + assert source, 'Cannot bind RTSP listener' + options = { + 'source': 'videotestsrc', + 'source_properties': 'is-live=true name="camera with two spaces"', + 'codec': codec, + 'encoder': encoder, + 'width': 320, + 'height': 240, + 'framerate': '30/1', + 'pixel_format': 'I420', + } + if codec != 'mjpeg': + options['keyframe_interval'] = 30 + producer = model_call('source_build', options) + producer += ' ! ' + model_call('encoding_build', options) + producer += ' ! ' + model_call('sender_build', {'.name': stream_name}) + command = json.loads(model_call('launch_argv', producer)) + command[command.index('-q')] = '-m' + capture = subprocess.Popen(command, stdout=subprocess.PIPE, text=True) + for message in capture.stdout: + if ('from element "pipeline0" (state-changed)' in message and + 'new-state=(GstState)playing' in message): + break + else: + raise AssertionError(f'{codec}: capture did not enter PLAYING') + pipeline_start( + f'rtspsrc location=rtsp://127.0.0.1:{server.get_bound_port()}/test ' + f'protocols=tcp latency=0 ! {depayloader} ! decodebin ! ' + 'appsink name=observer emit-signals=true sync=false', + observer=True, + ) + timeout = GLib.timeout_add_seconds(TEST_TIMEOUT_SECONDS, timeout_fail) + loop.run() + if not errors: + GLib.source_remove(timeout) + for pipeline in reversed(pipelines): + pipeline.set_state(Gst.State.NULL) + pipeline.get_bus().remove_signal_watch() + capture.terminate() + capture_status = capture.wait() + capture.stdout.close() + GLib.source_remove(source) + assert not errors, errors + assert capture_status in (0, -15), capture_status + assert len(frames) >= 3, frames + assert all('width=(int)320' in caps and 'height=(int)240' in caps for caps in frames) + print(f'{codec}: generated Unix FD handoff and RTSP client decoding passed') + + +for codec, encoder, depayloader in [ + ('mjpeg', 'jpegenc', 'rtpjpegdepay'), + ('h264', 'openh264enc', 'rtph264depay'), + ('h265', 'x265enc', 'rtph265depay'), +]: + codec_check(codec, encoder, depayloader) diff --git a/multimedia/gst1-rtsp-server/tests/watch.uc b/multimedia/gst1-rtsp-server/tests/watch.uc new file mode 100644 index 00000000000000..8ac1157b25dd93 --- /dev/null +++ b/multimedia/gst1-rtsp-server/tests/watch.uc @@ -0,0 +1,42 @@ +import { pipe } from 'fs'; +import * as uloop from 'uloop'; +import { watcher_create, QUIET_MS } from '../files/watch.uc'; + +function monotonic_ms() { + let value = clock(true); + return value[0] * 1000 + int(value[1] / 1000000); +} + +uloop.init(); +let started = pipe(), release = pipe(); +let scans = 0, applies = 0, last_event = monotonic_ms(); +let watcher = watcher_create(function() { + started[1].write('s'); + started[1].flush(); + assert(release[0].read(1) == 'r'); + return { camera: 'stable-camera' }; +}, function(result) { + assert(scans == 2); + assert(monotonic_ms() - last_event >= QUIET_MS); + assert(result.camera == 'stable-camera'); + applies++; + uloop.end(); +}, () => 'unchanged-topology'); +let handle = uloop.handle(started[0], function() { + assert(started[0].read(1) == 's'); + scans++; + if (scans == 1) { + last_event = monotonic_ms(); + watcher.event(); + } + release[1].write('r'); + release[1].flush(); +}, uloop.ULOOP_READ); +watcher.event(); +uloop.run(); +assert(scans == 2 && applies == 1); +watcher.stop(); +handle.delete(); +for (let file in [...started, ...release]) + file.close(); +print('15-second quiet period and in-flight scan invalidation passed\n');