From fc381c26766157148fbcdd746a49da7f395c8bcb Mon Sep 17 00:00:00 2001 From: toorux Date: Mon, 26 Oct 2026 01:00:39 +0800 Subject: [PATCH] Fix AirPlay startup and service discovery on Frame --- .github/workflows/release.yml | 4 +- docs/casting.md | 4 +- media/THIRD_PARTY_NOTICES.md | 8 +- media/dependencies.json | 5 ++ media/native/airplay_mdns.cpp | 152 ++++++++++++++++++++++++++++++++++ media/native/airplay_mdns.h | 12 +++ media/native/receiver.cpp | 28 ++++--- media/patch-uxplay.py | 42 +++++++++- tools/build-gst-good.sh | 17 ++++ tools/build-media.sh | 1 + tools/package-release.sh | 7 +- tools/test-casting-media.py | 33 +++++++- 12 files changed, 292 insertions(+), 21 deletions(-) create mode 100644 media/native/airplay_mdns.cpp create mode 100644 media/native/airplay_mdns.h create mode 100755 tools/build-gst-good.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6220920..5064dfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,8 +59,8 @@ jobs: sudo apt-get update sudo apt-get install -y g++ cmake pkg-config libgl1-mesa-dev libx11-dev \ libdrm-dev libvulkan-dev glslc libclang-dev libssl-dev libplist-dev \ - libavahi-compat-libdnssd-dev libgstreamer1.0-dev \ - libgstreamer-plugins-base1.0-dev libgupnp-1.6-dev \ + libavahi-compat-libdnssd-dev libavahi-core-dev libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev libgupnp-1.6-dev libpulse-dev libsoup-3.0-dev \ meson ninja-build libavfilter-dev libavformat-dev libavcodec-dev libavutil-dev - name: Check release version env: diff --git a/docs/casting.md b/docs/casting.md index 68e6c7f..bc5fce7 100644 --- a/docs/casting.md +++ b/docs/casting.md @@ -30,7 +30,7 @@ Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面” ## 接收与发送 -AirPlay 由固定版本 UxPlay 处理 RAOP 音频和屏幕镜像;只允许一个发送设备,不实现 AirPlay 发送。自定义 GStreamer sink 在用户确认前丢弃音频和画面。断开、拒绝或停止后清除同意状态,新连接重新询问。 +AirPlay 由固定版本 UxPlay 处理 RAOP 音频和屏幕镜像;只允许一个发送设备,不实现 AirPlay 发送。接收进程使用系统 Avahi core 库独立发布 AirPlay/RAOP 服务,不依赖系统 Avahi daemon 的发布权限,也不修改其全局配置。自定义 GStreamer sink 在用户确认前丢弃音频和画面。断开、拒绝或停止后清除同意状态,新连接重新询问。 DLNA 使用 GUPnP 的标准发现、SOAP 和事件订阅,接收 HTTP(S) 媒体地址。每个新请求先显示通知,接受后才下载、解码和播放。接收的音频打开内置控制窗口;视频采用旋转及像素比例校正后的实际画幅,控件浮在视频上并自动收起,不改变窗口画幅。不同 DLNA 请求可以保持多个空间窗口。播放器支持暂停、音量、停止以及媒体支持时的跳转;关闭空间窗口会停止该投屏。未处理请求 60 秒后过期。 @@ -42,7 +42,7 @@ DLNA 发送需要开启网络管理面板并启动串流。GUPnP 发现设备后 头显运行依赖现有 FFmpeg(libx264、libvpx、libopus、AAC、V4L2、PulseAudio)、GStreamer 1.24+、FFmpeg 7 共享库与随包的 gst-libav 插件、GUPnP 1.6、Avahi、OpenVR、Vulkan/DRM、Qualcomm Iris。设备上已确认 FFmpeg、GStreamer 1.24.2、GUPnP 1.6.6 和上述编码器可用。 -构建需要 Rust、C/C++ 编译器、CMake、Meson、Ninja、pkg-config、glslc,以及 DRM/Vulkan、GStreamer app/video、GUPnP 1.6、Avahi、OpenSSL/libplist 开发文件。`tools/build-media.sh` 构建采集助手、接收器、gst-libav 插件和 UxPlay,并下载校验固定版本的 MediaMTX。可用 `FRAMELY_SYSROOT` 为采集 crate 指定 ARM64 sysroot;gst-libav 链接 FFmpeg 7 ABI,其余组件使用目标系统的开发库。 +构建需要 Rust、C/C++ 编译器、CMake、Meson、Ninja、pkg-config、glslc,以及 DRM/Vulkan、GStreamer app/video、GUPnP 1.6、Avahi、PulseAudio、libsoup、OpenSSL/libplist 开发文件。`tools/build-media.sh` 构建采集助手、接收器、gst-libav、接收管线必需的 gst-plugins-good 插件和 UxPlay,并下载校验固定版本的 MediaMTX。可用 `FRAMELY_SYSROOT` 为采集 crate 指定 ARM64 sysroot;gst-libav 链接 FFmpeg 7 ABI,其余组件使用目标系统的开发库。 `tools/package-release.sh ` 同时构建媒体能力;助手放入 `bin`,其他媒体程序放入 `lib/media`。第三方许可证和媒体改动源码随包提供,版本和 SHA-256 位于 `media/dependencies.json`。 diff --git a/media/THIRD_PARTY_NOTICES.md b/media/THIRD_PARTY_NOTICES.md index f1e402f..a8a9261 100644 --- a/media/THIRD_PARTY_NOTICES.md +++ b/media/THIRD_PARTY_NOTICES.md @@ -18,11 +18,17 @@ Its version and checksum are pinned; releases include its source and license. CI links against the FFmpeg 7 development API to match SteamOS; that build dependency is pinned but its FFmpeg libraries are not distributed. +- [gst-plugins-good](https://gstreamer.freedesktop.org/modules/gst-plugins-good.html), + LGPL-2.1-or-later, supplies the bundled level, PulseAudio, video filter, + ISO MP4 and HTTP source plugins required by the receive pipelines. + The version and checksum are pinned; source, build recipe and license ship with releases. +- Avahi core (LGPL) is linked as a system library to publish only the receiver’s + AirPlay/RAOP records without changing system Avahi publishing settings. - GStreamer and GUPnP are linked as system libraries (LGPL). GStreamer performs decoding, audio playback, rotation and pixel aspect correction; GUPnP provides UPnP discovery, SOAP and event subscriptions. FFmpeg is invoked as the installed system executable for SteamVR capture, software encoding and audio muxing. - These system components are not copied into Framely packages. + The GStreamer core, GUPnP and their system libraries are not copied into Framely packages. - `native/json.hpp` is the existing nlohmann/json dependency (MIT), included with the main Framely release notices. diff --git a/media/dependencies.json b/media/dependencies.json index ed15319..5070620 100644 --- a/media/dependencies.json +++ b/media/dependencies.json @@ -18,5 +18,10 @@ "version": "7.0", "url": "https://ffmpeg.org/releases/ffmpeg-7.0.tar.xz", "sha256": "4426a94dd2c814945456600c8adfc402bee65ec14a70e8c531ec9a2cd651da7b" + }, + "gst-plugins-good": { + "version": "1.24.13", + "url": "https://gstreamer.freedesktop.org/src/gst-plugins-good/gst-plugins-good-1.24.13.tar.xz", + "sha256": "574ac6f9fd84b32eb04e80572391d6762df6f9802a47bc0386cd6cc48c14d08b" } } diff --git a/media/native/airplay_mdns.cpp b/media/native/airplay_mdns.cpp new file mode 100644 index 0000000..e76ca27 --- /dev/null +++ b/media/native/airplay_mdns.cpp @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Publish only UxPlay's records, independently of the system Avahi D-Bus policy. +#include "airplay_mdns.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct Service { + std::string name, type; + uint16_t port; + AvahiStringList* txt = nullptr; + AvahiSEntryGroup* group = nullptr; + ~Service() { + if (group) avahi_s_entry_group_free(group); + avahi_string_list_free(txt); + } +}; +class Publisher { + AvahiThreadedPoll* poll = nullptr; + AvahiServer* server = nullptr; + std::vector> services; + static void group_state(AvahiServer* server, AvahiSEntryGroup*, AvahiEntryGroupState state, void* data) { + auto* service = static_cast(data); + if (state == AVAHI_ENTRY_GROUP_ESTABLISHED) + std::fprintf(stderr, "AirPlay mDNS: published %s\n", service->type.c_str()); + else if (state == AVAHI_ENTRY_GROUP_COLLISION || state == AVAHI_ENTRY_GROUP_FAILURE) { + std::fprintf(stderr, "AirPlay mDNS: cannot publish %s: %s\n", service->type.c_str(), + state == AVAHI_ENTRY_GROUP_COLLISION ? "service name conflict" : avahi_strerror(avahi_server_errno(server))); + std::exit(1); + } + } + void publish(Service& service) { + if (!service.group) service.group = avahi_s_entry_group_new(server, group_state, &service); + if (!service.group) throw std::runtime_error(avahi_strerror(avahi_server_errno(server))); + if (!avahi_s_entry_group_is_empty(service.group)) return; + int error = avahi_server_add_service_strlst(server, service.group, AVAHI_IF_UNSPEC, + AVAHI_PROTO_UNSPEC, AvahiPublishFlags(0), service.name.c_str(), service.type.c_str(), + nullptr, nullptr, service.port, service.txt); + if (error >= 0) error = avahi_s_entry_group_commit(service.group); + if (error < 0) throw std::runtime_error(avahi_strerror(error)); + } + static void server_state(AvahiServer* server, AvahiServerState state, void* data) { + auto* self = static_cast(data); + // avahi_server_new can invoke us before it returns. + self->server = server; + try { + if (state == AVAHI_SERVER_RUNNING) { + for (auto& service : self->services) self->publish(*service); + } else if (state == AVAHI_SERVER_REGISTERING || state == AVAHI_SERVER_COLLISION) { + for (auto& service : self->services) + if (service->group) avahi_s_entry_group_reset(service->group); + if (state == AVAHI_SERVER_COLLISION) { + char* name = avahi_alternative_host_name(avahi_server_get_host_name(server)); + int error = avahi_server_set_host_name(server, name); + avahi_free(name); + if (error < 0) throw std::runtime_error(avahi_strerror(error)); + } + } else if (state == AVAHI_SERVER_FAILURE) { + throw std::runtime_error(avahi_strerror(avahi_server_errno(server))); + } + } catch (const std::exception& error) { + std::fprintf(stderr, "AirPlay mDNS: %s\n", error.what()); + std::exit(1); + } + } +public: + Publisher() { + avahi_set_log_function([](AvahiLogLevel level, const char* message) { + if (level <= AVAHI_LOG_WARN) std::fprintf(stderr, "AirPlay mDNS: %s\n", message); + }); + poll = avahi_threaded_poll_new(); + if (!poll) throw std::runtime_error("cannot create mDNS poll"); + AvahiServerConfig config; + avahi_server_config_init(&config); + // Share the machine hostname with other local mDNS stacks, avoiding + // conflicting reverse address records for the same network interfaces. + config.publish_hinfo = config.publish_workstation = config.publish_domain = 0; + config.enable_wide_area = 0; + int error = 0; + server = avahi_server_new(avahi_threaded_poll_get(poll), &config, server_state, this, &error); + avahi_server_config_free(&config); + if (!server || avahi_threaded_poll_start(poll) < 0) { + if (server) avahi_server_free(server); + avahi_threaded_poll_free(poll); + throw std::runtime_error(error ? avahi_strerror(error) : "cannot start mDNS poll"); + } + } + ~Publisher() { + avahi_threaded_poll_stop(poll); + services.clear(); + avahi_server_free(server); + avahi_threaded_poll_free(poll); + } + Service* add(const char* name, const char* type, uint16_t port, const void* txt, uint16_t size) { + std::unique_ptr service(new Service); + service->name = name; service->type = type; service->port = ntohs(port); + if (avahi_string_list_parse(txt, size, &service->txt) < 0) + throw std::runtime_error("invalid mDNS TXT record"); + avahi_threaded_poll_lock(poll); + try { + if (avahi_server_get_state(server) == AVAHI_SERVER_RUNNING) publish(*service); + auto* result = service.get(); + services.push_back(std::move(service)); + avahi_threaded_poll_unlock(poll); + return result; + } catch (...) { + // Free any registered group while its poll is still locked. + service.reset(); + avahi_threaded_poll_unlock(poll); + throw; + } + } + void remove(Service* service) { + avahi_threaded_poll_lock(poll); + services.erase(std::remove_if(services.begin(), services.end(), + [service](const std::unique_ptr& item) { return item.get() == service; }), services.end()); + avahi_threaded_poll_unlock(poll); + } +}; +Publisher& publisher() { static Publisher instance; return instance; } +} +extern "C" DNSServiceErrorType framely_mdns_register(DNSServiceRef* result, DNSServiceFlags flags, + uint32_t interface_index, const char* name, const char* type, const char* domain, const char* host, + uint16_t port, uint16_t size, const void* txt, DNSServiceRegisterReply callback, void*) { + if (!result || !name || !type || flags || interface_index || domain || host || callback) + return kDNSServiceErr_BadParam; + try { + *result = reinterpret_cast(publisher().add(name, type, port, txt, size)); + return kDNSServiceErr_NoError; + } catch (const std::exception& error) { + std::fprintf(stderr, "AirPlay mDNS initialization failed: %s\n", error.what()); + return kDNSServiceErr_Unknown; + } +} +extern "C" void framely_mdns_deallocate(DNSServiceRef service) { + publisher().remove(reinterpret_cast(service)); +} diff --git a/media/native/airplay_mdns.h b/media/native/airplay_mdns.h new file mode 100644 index 0000000..c47d653 --- /dev/null +++ b/media/native/airplay_mdns.h @@ -0,0 +1,12 @@ +#pragma once +#include +#ifdef __cplusplus +extern "C" { +#endif +DNSServiceErrorType framely_mdns_register(DNSServiceRef*, DNSServiceFlags, uint32_t, + const char*, const char*, const char*, const char*, uint16_t, uint16_t, + const void*, DNSServiceRegisterReply, void*); +void framely_mdns_deallocate(DNSServiceRef); +#ifdef __cplusplus +} +#endif diff --git a/media/native/receiver.cpp b/media/native/receiver.cpp index b373e72..bb45209 100644 --- a/media/native/receiver.cpp +++ b/media/native/receiver.cpp @@ -265,17 +265,21 @@ int main(int argc,char** argv) { try { if (argc<4) throw std::runtime_error("receiver DIRECTORY XMLDIR RECEIVE"); directory=argv[1]; xml_directory=argv[2]; receive_enabled=!strcmp(argv[3],"1"); gst_init(&argc,&argv); - // Frame's V4L2/Vulkan decoders can return corrupted frames and block - // playback controls. Prefer the bundled FFmpeg software decoders. - for (const char* name : {"avdec_h264", "avdec_h265"}) { - auto* decoder=gst_element_factory_find(name); - if (!decoder) throw std::runtime_error(std::string("Required decoder unavailable: ")+name); - gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_PRIMARY+100); - gst_object_unref(decoder); - } - for (const char* name : {"v4l2h264dec", "v4l2h265dec", "vulkanh264dec", "vulkanh265dec"}) { - auto* decoder=gst_element_factory_find(name); - if (decoder) { gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_NONE); gst_object_unref(decoder); } + // Decoder policy is only relevant to local DLNA playback. AirPlay uses + // UxPlay's own pipeline; discovery/control alone needs no video decoder. + if (receive_enabled) { + // Frame's V4L2/Vulkan decoders can return corrupted frames and block + // playback controls. Prefer the bundled FFmpeg software decoders. + for (const char* name : {"avdec_h264", "avdec_h265"}) { + auto* decoder=gst_element_factory_find(name); + if (!decoder) throw std::runtime_error(std::string("Required decoder unavailable: ")+name); + gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_PRIMARY+100); + gst_object_unref(decoder); + } + for (const char* name : {"v4l2h264dec", "v4l2h265dec", "vulkanh264dec", "vulkanh265dec"}) { + auto* decoder=gst_element_factory_find(name); + if (decoder) { gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_NONE); gst_object_unref(decoder); } + } } auto* manager=gupnp_context_manager_create_full(GSSDP_UDA_VERSION_1_0,G_SOCKET_FAMILY_IPV4,0); g_signal_connect(manager,"context-available",G_CALLBACK(context_available),nullptr); @@ -285,5 +289,5 @@ int main(int argc,char** argv) { for (auto* p:transports) g_object_unref(p); for (auto* p:renderers) g_object_unref(p); g_io_channel_unref(channel); g_object_unref(manager); g_main_loop_unref(loop); return 0; - } catch (const std::exception& e) { output({{"event","fatal"},{"error",e.what()}}); return 1; } + } catch (const std::exception& e) { std::cerr << "DLNA receiver: " << e.what() << std::endl; output({{"event","fatal"},{"error",e.what()}}); return 1; } } diff --git a/media/patch-uxplay.py b/media/patch-uxplay.py index e1209dd..664574d 100644 --- a/media/patch-uxplay.py +++ b/media/patch-uxplay.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Add only lifecycle events to UxPlay; protocol/decoder code remains upstream.""" +"""Adapt UxPlay lifecycle events and explicit sinks; protocol/decoder code remains upstream.""" from pathlib import Path import sys p=Path(sys.argv[1])/'uxplay.cpp' @@ -123,3 +123,43 @@ if 'Framely HLS consent' not in s: } ''',1) renderer.write_text(s) + +# Framely supplies both sinks explicitly; autodetect is not part of its pipeline. +# Keep upstream checks unchanged for all other UxPlay output modes. +audio=p.parent/'renderers/audio_renderer.c' +s=audio.read_text() +if 'framely_explicit_sinks' not in s: + assert 'static gboolean check_plugins (void)' in s + s=s.replace('static gboolean check_plugins (void)', 'static gboolean check_plugins (bool needs_autodetect)',1) + marker=' GstPlugin *plugin = NULL;' + assert marker in s + s=s.replace(marker, ' /* framely_explicit_sinks: no automatic device selection needed. */\n if (!needs_autodetect && !strcmp(needed[i], "autodetect")) continue;\n'+marker,1) + s=s.replace('bool gstreamer_init(){','bool gstreamer_init(bool needs_autodetect){',1) + s=s.replace('return (bool) check_plugins ();','return (bool) check_plugins (needs_autodetect);',1) + audio.write_text(s) +header=p.parent/'renderers/audio_renderer.h' +s=header.read_text() +if 'bool gstreamer_init();' in s: + header.write_text(s.replace('bool gstreamer_init();','bool gstreamer_init(bool needs_autodetect);',1)) +s=p.read_text() +if 'if (!gstreamer_init())' in s: + s=s.replace('if (!gstreamer_init())', 'if (!gstreamer_init(!(videosink == "framelyvideosink" && audiosink == "framelyaudiosink")))',1) + p.write_text(s) + +# SteamOS can disable publication in the system Avahi daemon. Use its existing +# Avahi core library inside the receiver instead; leave system policy untouched. +for name in ('airplay_mdns.h', 'airplay_mdns.cpp'): + copyfile(Path(__file__).parent/'native'/name, p.parent/'framely'/name) +dns=p.parent/'lib/dnssd.c' +s=dns.read_text() +if 'framely_mdns_register' not in s: + s=s.replace('#include ', '#include \n#include "../framely/airplay_mdns.h"', 1) + marker=' dnssd->name_len = name_len;' + assert marker in s + s=s.replace(marker, ' if (getenv("FRAMELY_CAST_DIR")) {\n dnssd->DNSServiceRegister = &framely_mdns_register;\n dnssd->DNSServiceRefDeallocate = &framely_mdns_deallocate;\n }\n\n'+marker, 1) + dns.write_text(s) +cmake=p.parent/'lib/CMakeLists.txt' +s=cmake.read_text() +if 'framely/airplay_mdns.cpp' not in s: + s+='\ntarget_sources(airplay PRIVATE ../framely/airplay_mdns.cpp)\npkg_check_modules(FRAMELY_AVAHI REQUIRED IMPORTED_TARGET avahi-core)\ntarget_link_libraries(airplay PUBLIC PkgConfig::FRAMELY_AVAHI)\n' + cmake.write_text(s) diff --git a/tools/build-gst-good.sh b/tools/build-gst-good.sh new file mode 100755 index 0000000..ca7d8a9 --- /dev/null +++ b/tools/build-gst-good.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +base=$(cd -- "$(dirname -- "$0")/.." && pwd) +cd "$base" +# SteamOS does not provide these Good plugins. Build only the elements used +# by our receive pipelines; keep GStreamer, PulseAudio and Soup system libraries. +meson setup media/source/gst-good-build media/source/gst-plugins-good-1.24.13 \ + --prefix=/usr --libdir=lib --buildtype=release --wrap-mode=nofallback \ + -Dauto_features=disabled -Dlevel=enabled -Dpulse=enabled \ + -Dvideofilter=enabled -Disomp4=enabled -Dsoup=enabled \ + -Dtests=disabled -Dexamples=disabled -Ddoc=disabled +ninja -C media/source/gst-good-build -j"${FRAMELY_BUILD_JOBS:-4}" +DESTDIR="$base/media/source/gst-good-install" meson install -C media/source/gst-good-build --no-rebuild +for plugin in level pulseaudio videofilter isomp4 soup; do + cp "media/source/gst-good-install/usr/lib/gstreamer-1.0/libgst${plugin}.so" media/bin/ +done +cp media/source/gst-plugins-good-1.24.13/COPYING media/bin/LICENSE.gst-good diff --git a/tools/build-media.sh b/tools/build-media.sh index dcaeeb1..ed099d6 100755 --- a/tools/build-media.sh +++ b/tools/build-media.sh @@ -23,6 +23,7 @@ cp "$capture_target/release/framely-capture" "$capture_target/release/framely-pa # Native dependencies use the target system's GStreamer and GUPnP libraries. python3 tools/fetch-media.py bash tools/build-gst-libav.sh +bash tools/build-gst-good.sh python3 media/patch-uxplay.py media/source/UxPlay-1.73.7 cmake -S media/source/UxPlay-1.73.7 -B media/source/uxplay-build -DNO_X11_DEPS=ON -DCMAKE_BUILD_TYPE=Release cmake --build media/source/uxplay-build -j"${FRAMELY_BUILD_JOBS:-4}" diff --git a/tools/package-release.sh b/tools/package-release.sh index 6b8cf5c..232383e 100755 --- a/tools/package-release.sh +++ b/tools/package-release.sh @@ -16,7 +16,7 @@ if [[ ${FRAMELY_USE_PREBUILT_MEDIA:-0} == 1 ]]; then else [[ -z $(git status --porcelain -- media) ]] || { echo 'Media sources changed; rebuild media.' >&2; exit 1; } fi - for file in media/bin/framely-panel-grab media/bin/framely-capture media/bin/mediamtx media/bin/uxplay media/bin/framely-receiver media/bin/libgstframely.so media/bin/libgstlibav.so media/bin/LICENSE media/bin/LICENSE.uxplay media/bin/LICENSE.gst-libav media/bin/LICENSE.llhttp; do + for file in media/bin/framely-panel-grab media/bin/framely-capture media/bin/mediamtx media/bin/uxplay media/bin/framely-receiver media/bin/libgstframely.so media/bin/libgstlibav.so media/bin/LICENSE media/bin/LICENSE.uxplay media/bin/LICENSE.gst-libav media/bin/LICENSE.llhttp media/bin/LICENSE.gst-good media/bin/libgstlevel.so media/bin/libgstpulseaudio.so media/bin/libgstvideofilter.so media/bin/libgstisomp4.so media/bin/libgstsoup.so; do [[ -s $file ]] || { echo "Missing prebuilt media: $file" >&2; exit 1; } done else @@ -39,18 +39,21 @@ mkdir -p "$stage"/{bin,lib/cef,lib/openvr,lib/media,share/ui,share/licenses,tool cp "$binary" "$stage/bin/" cp media/bin/framely-panel-grab "$stage/bin/" cp media/bin/framely-capture media/bin/mediamtx media/bin/uxplay media/bin/framely-receiver media/bin/libgstframely.so media/bin/libgstlibav.so "$stage/lib/media/" +cp media/bin/libgst{level,pulseaudio,videofilter,isomp4,soup}.so "$stage/lib/media/" cp -a media/bin/upnp "$stage/lib/media/" cp media/bin/LICENSE "$stage/share/licenses/mediamtx.txt" cp media/bin/LICENSE.uxplay "$stage/share/licenses/uxplay.txt" cp media/bin/LICENSE.gst-libav "$stage/share/licenses/gst-libav.txt" +cp media/bin/LICENSE.gst-good "$stage/share/licenses/gst-plugins-good.txt" cp media/bin/LICENSE.llhttp "$stage/share/licenses/uxplay-llhttp.txt" cp media/capture/LICENSE.framecorder "$stage/share/licenses/panel-capture.txt" cp media/THIRD_PARTY_NOTICES.md "$stage/share/licenses/media-notices.md" mkdir -p "$stage/share/source/media/capture" "$stage/share/source/tools" "$stage/share/source/native/vendor" cp -a media/native media/upnp media/patch-uxplay.py media/dependencies.json "$stage/share/source/media/" cp -a media/capture/src media/capture/shaders media/capture/Cargo.toml media/capture/Cargo.lock media/capture/build.rs media/capture/LICENSE.framecorder "$stage/share/source/media/capture/" -cp tools/build-media.sh tools/build-gst-libav.sh tools/fetch-media.py "$stage/share/source/tools/" +cp tools/build-media.sh tools/build-gst-libav.sh tools/build-gst-good.sh tools/fetch-media.py "$stage/share/source/tools/" cp native/vendor/json.hpp "$stage/share/source/native/vendor/" +tar -cJf "$stage/share/source/media/gst-plugins-good.tar.xz" -C media/source gst-plugins-good-1.24.13 tar -cJf "$stage/share/source/media/gst-libav.tar.xz" -C media/source gst-libav-1.24.13 tar -czf "$stage/share/source/media/uxplay.tar.gz" -C media/source UxPlay-1.73.7 diff --git a/tools/test-casting-media.py b/tools/test-casting-media.py index c8c9eae..619dfe1 100644 --- a/tools/test-casting-media.py +++ b/tools/test-casting-media.py @@ -9,10 +9,41 @@ binaries=Path(args.binaries).resolve() with tempfile.TemporaryDirectory(prefix='framely-media-test-') as directory: root=Path(directory);events=socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM);events.bind(str(root/'events.sock'));events.settimeout(5) token='0123456789abcdef0123456789abcdef';env=dict(os.environ,GST_PLUGIN_PATH=str(binaries),FRAMELY_CAST_DIR=directory,FRAMELY_CAST_ID=token) - airplay=subprocess.Popen([str(binaries/'uxplay'),'-n','Framely test','-avdec','-hls','-vs','framelyvideosink','-as','framelyaudiosink'],env=env,stdout=subprocess.PIPE,stderr=subprocess.STDOUT) + # Check the actual receive elements, including plugins absent on stock Frame. + for element in ['framelyvideosink','framelyaudiosink','level','pulsesink','videoflip','souphttpsrc','qtdemux']: + result=subprocess.run(['gst-inspect-1.0','--exists',element],env=env,capture_output=True) + assert result.returncode==0,f'Required receive element unavailable: {element}' + test_name=f'Framely test {os.getpid()}' + airplay=subprocess.Popen([str(binaries/'uxplay'),'-n',test_name,'-nh','-m','-avdec','-hls','-vs','framelyvideosink','-as','framelyaudiosink'],env=env,stdout=subprocess.PIPE,stderr=subprocess.STDOUT) try: time.sleep(2);assert airplay.poll() is None,airplay.stdout.read().decode() print('PASS: UxPlay starts with bundled decoders and consent sinks') + # Use the system daemon only to browse. Publication must also work when + # that daemon's disable-publishing policy is enabled (Frame's default). + import shutil + if shutil.which('avahi-browse'): + for service_type in ['_airplay._tcp','_raop._tcp']: + browser=subprocess.Popen(['avahi-browse','--resolve','--parsable', + '--no-db-lookup',service_type],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True) + from queue import Queue,Empty + discovered=Queue() + def browse_lines(): + for line in browser.stdout:discovered.put(line) + Thread(target=browse_lines,daemon=True).start() + try: + deadline=time.monotonic()+15 + while True: + try:line=discovered.get(timeout=max(.01,deadline-time.monotonic())) + except Empty:raise AssertionError(f'No resolved {service_type} advertisement') + if line.startswith('=;') and test_name in line.replace('\\032',' '): + assert 'features=' in line or 'cn=' in line,line + break + assert time.monotonic()