众所周知,某会议软件推出的 Linux 版本在 Wayland session 下是拿不到屏幕的信息的(虽然某种程度上,能有 Linux 版本就已经很不错了)。本文提出了一种扭曲但是可行性更高且更加可靠的方法,并且包括了一些相关的笔记,以及我的一些推测,以备参考。

如果你看不到本文的视频……

我也传了一份到 A 站上:https://www.acfun.cn/v/ac41424863

为什么不是传到 B 站上?我其实一开始想这么搞,但是发现现在 B 站的投稿已经没有分 P 功能了(我上次上传视频得好几年以前了),然后合集功能还要「达到一定电磁力」(这玩意和用户等级还是两个不同的东西)才能用。我又不想在别人的 timeline 上蹦出七个怪视频,所以就算了。B 站的产品经理可能脑子有大病吧。

A 站的快手服务器好像有点问题,我这里太大的视频传的时候会卡住,但是至少分 P 功能还在,不是不能用。

而 YouTube 对应的功能是「播放列表」,而且我可以设置视频不列出在频道里面,所以没有问题。而且原始录制的视频有点大,直接塞个 video 标签,然后让人一开页面就下载几百兆的东西显然不是什么明智的选择。

不过说句题外话,A 站好像也挺惨的,我之前注册账号主要是为了看《摇曳露营》,然后被搞出先审后播之后就没新番了,在竞争上处于明显的劣势。

tl;dr

(Updated 2024/03/17)

简单来说,该会议软件只能在 rootful xwayland 下才能获取到 xwayland 整个屏幕的信息。你可以先开个 nested wayland compositor(比如说 weston),也可以直接开这个 rootful xwayland(我不清楚 Server-Side Decoration 下直接开有没有边框,不过如果是 CSD-only 的环境(aka, GNOME)的话,需要编译的时候带上 libdecor 参数。作为参考,可以使用我打的 AUR 包:xwayland-standalone-with-libdecor

在这个 rootful xwayland 下面就可以做很多传统 X 的事情了,比如说开个 WM(我推荐用 OpenBox)之类的。

最后缺失的一环是把外面的内容传进来,而 xdg portal 暴露了屏幕共享的接口,可以输出一个 pipewire fd,之后 gstreamer 可以把这个 fd 输出到一个 X 窗口,于是就实现了这个目标。xdp-screen-cast.py 脚本就帮忙完成了写 DBus 请求等等的麻烦事。

不太好的方案

虚拟摄像头?

这种方法似乎是在使用 native 客户端的前提下最可行的。我没有测试过这种方案(因为不想装 dkms),但是有一点不能忽略的是:摄像头的视频编码和屏幕共享的视频编码参数肯定是不同的。摄像头拍到的人脸糊一点,artifacts 多一点,其实问题都不算大;但是屏幕共享糊一点的话,屏幕上的字都看不清楚了,这在很多场景下都是不能接受的。

xwaylandvideobridge?

看起来很完美,但是在这里唯一的问题是:它不能用(至少我本地测试是如此)。因为它解决的是使用了 XRecord 扩展的软件的录屏问题,但是某会议软件似乎并没有使用 XRecord 扩展。 使用 XRecord 扩展来判断程序是否在录屏(使用 X 混成扩展重定向窗口内容),但是这里似乎检测不到。

AUR 的评论区里前几天有人说可以,我不太确定可信度(可能是我打开的方式不对?),如果有人测试过可行的话可以告诉我一下。 问了一下,确实是不行的。

Windows VM + OBS/RDP?

大概也算是一种勉强能用的方案。OBS + rtmp 推流会有 2s 的延迟,如果走 RDP 延迟会好一些(GNOME 用的是 gnome-remote-desktop,其他的桌面环境应该有类似的方案)。但是:

  • 更耗电;
  • 如果是 KVM 用户,且没有显卡虚拟化,只有 QXL 2D 加速,那么投屏的时候 Windows 的 CPU 占用率会奇高。

Proposal

调研过 Wayland 的 Portal 投屏的用户可能都看过这个 snippet: https://gitlab.gnome.org/-/snippets/19

它的工作原理是:

  1. 和对应的 DBus 接口通信,拿到一个 pipewire 的 fd(well,我不关心具体是怎么调用这个 portal 的);
  2. 用 GStreamer 打开这个 fd,然后经过一个 pipeline 之后用 xvimagesink 播放(显示在弹出的窗口中)。

xvimagesink 是一个使用 XVideo 扩展 显示视频流的 X11 窗口,所以理论上我们可以在一个独立的 X display 里面运行某会议软件,然后让 xvimagesink 播放到这个 display 里面,这样的话就可以识别到了。

x11docker & GStreamer?

如果要说再跑一个 X session 的话,x11docker 应该是一个不错的选择,基于容器隔离,并且有一大堆自定义的参数可以选择。然后接下来的问题就是怎么把 GStreamer 的视频流传输到 x11docker 的 X display 里面。由于用户权限等问题,直接在外面 DISPLAY 感觉不太像是一个靠谱的主意,因此我当时的想法是,通过网络把视频流传到容器里面的 GStreamer,然后由它显示出来。

这不是最终的解决方案,如果只想看解决方案,可以直接拉到下一大节。

GStreamer 101

GStreamer 我感觉资料不多,并且诡异的是,在我搜索的时候,可以在 StackOverflow 上找到一些 GStreamer 标签的问题,但是它们大多不约而同没有任何人回答。所以我觉得可能有必要写一下我在倒腾的时候的一些认识,虽然我不能保证它们完全正确。

首先我们基本不需要去写代码,大部分工作都可以通过构建 pipeline 解决。GStreamer 的 pipeline 有一点像 Unix 的管道机制。开头是一个生产多媒体流的 “source”,中间会经过一些 “filter” 对多媒体流做一些处理,最后 “sink” 是这个流的消费者。这些组成 pipeline 的源部件被称为「元素」(Element)。

我们可以用 gst-launch-1.0 做简单的测试用途。一个最简单的例子是:

gst-launch-1.0 videotestsrc ! autovideosink

这里 videotestsrc 是产生测试图像信号的 source,而 autovideosink 是 sink,会根据平台选择最合适的 video sink 展示给用户。

之后可以放一点我们自己的视频。filesrc location=path/to/file 是从文件中读取数据的 source,于是这样就可以了吗?

gst-launch-1.0 filesrc location=./test.mp4 ! autovideosink

执行会发现:

Setting pipeline to PAUSED ...
Pipeline is PREROLLING ...

(gst-launch-1.0:1107112): GStreamer-Video-CRITICAL **: 13:46:40.380: gst_video_info_to_caps: assertion 'info->finfo != NULL' failed
ERROR: from element /GstPipeline:pipeline0/GstAutoVideoSink:autovideosink0/GstXvImageSink:autovideosink0-actual-sink-xvimage: Internal error: can't allocate images
Additional debug info:
../gstreamer/subprojects/gst-plugins-base/sys/xvimage/xvimagesink.c(1169): gst_xv_image_sink_show_frame (): /GstPipeline:pipeline0/GstAutoVideoSink:autovideosink0/GstXvImageSink:autovideosink0-actual-sink-xvimage:
We don't have a bufferpool negotiated
ERROR: pipeline doesn't want to preroll.
Setting pipeline to NULL ...
ERROR: from element /GstPipeline:pipeline0/GstFileSrc:filesrc0: Internal data stream error.
Additional debug info:
../gstreamer/subprojects/gstreamer/libs/gst/base/gstbasesrc.c(3132): gst_base_src_loop (): /GstPipeline:pipeline0/GstFileSrc:filesrc0:
streaming stopped, reason error (-5)
ERROR: pipeline doesn't want to preroll.
Freeing pipeline ...

这是因为 filesrc 产生的数据类型和视频文件的编码一致,而 autovideosink(实际上是 xvimagesink)接收的数据类型是 video/x-raw,所以我们需要添加一个解码的 filter: decodebin

gst-launch-1.0 filesrc location=./test.mp4 ! decodebin ! autovideosink

对于上面的 xdp-screen-cast 的 snippet,我们可以看到它的 pipeline 是:

pipewiresrc fd=%d path=%u ! videoconvert ! xvimagesink

这里 videoconvert 是用来在 raw 视频流之间做色彩空间转换的。但是 pipewiresrc 输出的视频流到底是什么样的呢?pipewiresrc 不是 GStreamer 官方的插件,所以如果要看它的文档,需要使用 gst-inspect-1.0

gst-inspect-1.0 pipewiresrc

虽然看不到有效的信息:

Pad Templates:
  SRC template: 'src'
    Availability: Always
    Capabilities:
      ANY

Clocking Interaction:
  element is supposed to provide a clock but returned NULL
Element has no URI handling capabilities.

Pads:
  SRC: 'src'
    Pad Template: 'src'

估计它的输出的类型和 pipewire 源有关系,我们可以修改 xdp-screen-cast 的代码,加上一些用来输出相关信息的代码。

gst_command = "pipewiresrc fd=%d path=%u name=pwsrc0 ! videoconvert ! xvimagesink"
gst_command = gst_command % (fd, node_id)

pipeline = Gst.parse_launch(gst_command)

def pad_func(pad, info, user_data):
    caps = pad.get_current_caps()
    if caps:
        print("Video stream type:", caps.to_string())
    else:
        print("No caps available")
    return Gst.PadProbeReturn.PASS

pwsrc = pipeline.get_by_name("pwsrc0")
srcpad = pwsrc.get_static_pad("src")
srcpad.add_probe(Gst.PadProbeType.EVENT_DOWNSTREAM, pad_func, None)

pipeline.set_state(Gst.State.PLAYING)
# ...

这里我们给 pipewiresrc 加上了一个名字 pwsrc0,然后在播放这个 pipeline 之前获取了它的 “src” pad,然后为这个 pad 加上了一个 probe,这个类似钩子的东西会调用 pad_func() 函数,函数内会输出 “pad” 的 “caps”。

这里,“pad” 可以看作是让数据输入或者输出元素的通道,而 pad 的 “caps” (capabilities) 则代表了哪些类型的数据可以被对应的 pad 处理。在上面的代码中我们添加的 probe 则是一类用来通知应用程序数据流状态的 callback。而 “EVENT_DOWNSTREAM” 则代表在这个 pipeline 中从上游 (source) 到下游 (sink) 的事件。

执行可以看到:

session /org/freedesktop/portal/desktop/session/1_5365/u1 created
sources selected
streams:
stream 144
No caps available
Video stream type: video/x-raw, format=(string)BGRx, width=(int)1920, height=(int)1200, framerate=(fraction)0/1, max-framerate=(fraction)15729223/262144
Video stream type: video/x-raw, format=(string)BGRx, width=(int)1920, height=(int)1200, framerate=(fraction)0/1, max-framerate=(fraction)15729223/262144

由此可以知道,这里 pipewiresrc 的输出是一个 1920*1080 大小的,像素格式为 BGRx 的 raw 视频流。对 xvimagesink 做相似的修改,可以观察到输出的视频流是:

Video stream type: video/x-raw, width=(int)1920, height=(int)1200, framerate=(fraction)0/1, max-framerate=(fraction)15729223/262144, format=(string)YV12

因此 videoconvert 在中间将 BGRx 转换为了 YV12 的格式。同样的,如果你有摄像头,也可以试一下下面这个 pipeline:

gst-launch-1.0 v4l2src device=/dev/video0 ! videoconvert ! autovideosink

另外,调试的时候可以加 GST_DEBUG="3" 环境变量以输出错误信息(增大数字可以输出更多的信息)。

GStreamer 与网络传输 (1)

如果两端都安装了 GStreamer,那么可以比较方便地实现网络传输视频流。以 UDP 为例子,发送端需要将数据流扔给 udpsink,而接收端需要从 udpsrc 读取这个数据流。但是直接 naively 加上是不工作的:

> gst-launch-1.0 videotestsrc ! udpsink host=127.0.0.1 port=5000
Setting pipeline to PAUSED ...
Pipeline is PREROLLING ...
Pipeline is PREROLLED ...
Setting pipeline to PLAYING ...
Redistribute latency...
New clock: GstSystemClock
WARNING: from element /GstPipeline:pipeline0/GstUDPSink:udpsink0: Attempting to send a UDP packets larger than maximum size (614400 > 65507)
Additional debug info:
../gstreamer/subprojects/gst-plugins-good/gst/udp/gstmultiudpsink.c(684): gst_multiudpsink_send_messages (): /GstPipeline:pipeline0/GstUDPSink:udpsink0:
Reason: Error sending message: Message too long
WARNING: from element /GstPipeline:pipeline0/GstUDPSink:udpsink0: Attempting to send a UDP packets larger than maximum size (614400 > 65507)
Additional debug info:
../gstreamer/subprojects/gst-plugins-good/gst/udp/gstmultiudpsink.c(684): gst_multiudpsink_send_messages (): /GstPipeline:pipeline0/GstUDPSink:udpsink0:
Reason: Error sending message: Message too long
# 以下省略

UDP 的单个包大小有上限。那么有人会说,能不能用 TCP 呢?

> gst-launch-1.0 videotestsrc ! tcpserversink host=127.0.0.1 port=5000
Setting pipeline to PAUSED ...
Pipeline is PREROLLING ...
Pipeline is PREROLLED ...
Setting pipeline to PLAYING ...
Redistribute latency...
New clock: GstSystemClock
0:00:04.3 / 99:99:99.
# 好像没问题?开个新窗口看看接收端
> gst-launch-1.0 tcpclientsrc port=5000 ! autovideosink
Setting pipeline to PAUSED ...
Pipeline is PREROLLING ...

(gst-launch-1.0:1188556): GStreamer-Video-CRITICAL **: 02:14:55.643: gst_video_info_to_caps: assertion 'info->finfo != NULL' failed
ERROR: from element /GstPipeline:pipeline0/GstAutoVideoSink:autovideosink0/GstXvImageSink:autovideosink0-actual-sink-xvimage: Internal error: can't allocate images
Additional debug info:
../gstreamer/subprojects/gst-plugins-base/sys/xvimage/xvimagesink.c(1169): gst_xv_image_sink_show_frame (): /GstPipeline:pipeline0/GstAutoVideoSink:autovideosink0/GstXvImageSink:autovideosink0-actual-sink-xvimage:
We don't have a bufferpool negotiated
ERROR: pipeline doesn't want to preroll.
ERROR: from element /GstPipeline:pipeline0/GstTCPClientSrc:tcpclientsrc0: Internal data stream error.
Additional debug info:
../gstreamer/subprojects/gstreamer/libs/gst/base/gstbasesrc.c(3132): gst_base_src_loop (): /GstPipeline:pipeline0/GstTCPClientSrc:tcpclientsrc0:
streaming stopped, reason error (-5)
ERROR: pipeline doesn't want to preroll.
Setting pipeline to NULL ...
Freeing pipeline ...

即使能发出去,接收端也无法正常播放,因为它对视频流的属性一无所知。即使我们加上了相关的属性,得到的视频流输出也是没法用的:

# 发送端?
> gst-launch-1.0 videotestsrc ! videoconvert ! video/x-raw,format=BGRx,width=100,height=100,framerate=24/1 ! tcpserversink host=127.0.0.1 port=5000
# 接收端?
> gst-launch-1.0 tcpclientsrc port=5000 ! video/x-raw,format=BGRx,width=100,height=100,framerate=24/1 ! videoconvert ! autovideosink

得到的输出是绿屏,可能的原因是这里接收端没有足够多的信息判断每一帧怎么划分。

对于 UDP,为了让视频流能够正确在网络通讯中存活,一般的做法是用 RTP 包一层。发送的时候用 rtpvrawpay 包,接收的时候用 rtpvrawdepay 解包。

# 发送端
# RTP 不支持上面例子的 BGRx,所以这里换成了 BGR
# 因为 videotestsrc 支持很多格式,所以 format 随便填一个应该就行
> gst-launch-1.0 videotestsrc ! videoconvert ! video/x-raw,format=BGR,width=100,height=100,framerate=24/1 ! rtpvrawpay ! udpsink host=127.0.0.1 port=5000
# 接收端
# rtpvrawdepay 要求上游给属性,这里 udpsrc 接收到的是 rtp 包(而不是直接的 raw),所以得按照 rtp 的格式写
# caps 对类型的要求比较严格,如果 width/height 不声明为 `string` 会报错,而且得加上 GST_DEBUG 才能知道是这里的问题
# 只能参考文档猜字符串要怎么写,有些默认值这里省略了,其他的教程可能会写进去
> gst-launch-1.0 udpsrc port=5000 caps='application/x-rtp,sampling=(string)BGR,width=(string)100,height=(string)100,depth=(string)8' ! rtpvrawdepay ! videoconvert ! autovideosink

这也太麻烦了,如果搜过会发现,好像编码成 H.264 之后就不用写(猜)这么一长串格式了:

# 发送端
# 这里 tune=zerolatency 先加上了,否则延迟可能难以接受
> gst-launch-1.0 videotestsrc ! videoconvert ! x264enc tune=zerolatency ! rtph264pay ! udpsink host=127.0.0.1 port=5000
# 接收端
> gst-launch-1.0 udpsrc port=5000 caps='application/x-rtp,encoding-name=(string)H264' ! rtph264depay ! avdec_h264 ! videoconvert ! autovideosink

或者 MJPEG 也可以:

# 发送端
> gst-launch-1.0 videotestsrc ! videoconvert ! jpegenc ! rtpjpegpay ! udpsink host=127.0.0.1 port=5000
# 接收端
> gst-launch-1.0 udpsrc port=5000 caps='application/x-rtp,encoding-name=(string)JPEG' ! rtpjpegdepay ! jpegdec ! videoconvert ! autovideosink

而对于 TCP,因为 TCP 是流式的(它关心的只是怎么传输一个字节流),而不是像 UDP 那样一个包一个包界限分明,但是 RTP 依赖于 UDP 的这个性质,所以 TCP 下需要用某种编码包一层,或者用 GDP (GStreamer Data Protocol) 包一层:

# 发送端
> gst-launch-1.0 videotestsrc ! videoconvert ! video/x-raw,format=BGRx,width=100,height=100,framerate=24/1 ! gdppay ! tcpserversink host=127.0.0.1 port=5000
# 接收端
> gst-launch-1.0 tcpclientsrc port=5000 ! gdpdepay ! video/x-raw,format=BGRx,width=100,height=100,framerate=24/1 ! videoconvert ! autovideosink

视频编码?

看起来视频编码是一个不错的主意,但是我们需要考虑两点问题:

  • 视频编码的质量、延迟
  • 视频编码的性能开销

带宽占用可能也是一个值得关注的问题,不过我们这里的场景是本地传输,所以甚至直接传 raw stream 都是可以接受的。

我们可以在 pipeline 中添加先编码然后直接解码后输出到 xvimagesink 的过程。rtp pay 和 depay 的损耗几乎可以忽略不计,所以这里就不放了。

# H.264
pipewiresrc fd=%d path=%u ! videoconvert ! x264enc tune=zerolatency ! avdec_h264 ! videoconvert ! xvimagesink
# MJPEG
pipewiresrc fd=%d path=%u ! jpegenc ! jpegdec ! videoconvert ! xvimagesink

首先可以发现的是,默认参数下,H.264 + zerolatency 的效果非常非常糊:

Blurry stream in H264

和朋友讨论了之后发现是默认码率的问题。GStreamer 的 x264enc 的默认码率是 2048 Kbps。这个码率用在摄像头上大概可以勉强看清人脸,然后在屏幕共享的场合就很不够用了。尤其是这里还开了 zerolatency,把很多非实时场景下能用的优化都关掉了,所以需求的码率更高。

(其实可能可以妥协少量的延迟来做一些帧间优化,但是我不太熟悉怎么去调参数,所以就跳过吧)

将码率调高之后画质的问题有所改善,但是还是不怎么跟手。如果只是共享 slides 之类的话可能问题不算很大,不过如果要以很低的延迟共享游戏的屏幕流的话,恐怕都不太合适。

以下是 MJPEG, H.264 (30000 Kbps) 和不编码/解码的时候延迟与 CPU 使用量的直观对比,其中右下角是 xvimagesink:

MJPEG

H.264 (30000 Kbps)

直接输出

如果开个游戏的话,延迟带来的效果就更加显著:

MJPEG,某个(被)弹幕(打的)游戏的 screencast

所以显然转码和解码会带来一定的开销,并且这样的开销有的时候无法忽略——不过如果需要考虑网络传输的开销的话,MJPEG 相比来讲仍然是一个比较均衡的方案,所以仍然需要视场景选择。

有读者可能会问:为什么不用硬件加速?GStreamer 确实支持基于 VAAPI 的硬件加速,但是它的 H.264 硬件加速编码器不支持 zerolatency,实际效果反而更卡;而 MJPEG 我没有找到相关的硬件加速方案,不过 VAAPI 支持其他类型的编解码加速,这就需要有兴趣的人自己测试了。

另外再次需要注意的是,这里考虑的是一个极端的场合(需要很小的延迟)。在直播之类的场景下反而 H.264(以及其他更先进的编码)是更好的选择,因为几秒的延迟已经足够做优化,并且这个时候也可以用上硬件编码。

网络传输 (2)

这个问题听起来很简单——毕竟前面已经知道怎么在 GStreamer 里面使用 UDP/TCP 来传输视频流了,那么直接用上不就行了?让我们先拿个视频文件试试吧!

# 发送端 (UDP)
> gst-launch-1.0 filesrc location=./test.mp4 ! decodebin ! videoconvert ! jpegenc ! rtpjpegpay ! udpsink host=127.0.0.1 port=5000  # MJPEG
> gst-launch-1.0 filesrc location=./test.mp4 ! decodebin ! videoconvert ! x264enc tune=zerolatency bitrate=30000 ! rtph264pay ! udpsink host=127.0.0.1 port=5000  # H.265
> gst-launch-1.0 filesrc location=./test.mp4 ! decodebin ! videoconvert ! rtpvrawpay ! udpsink host=127.0.0.1 port=5000  # RAW
# 接收端 (UDP)
> gst-launch-1.0 udpsrc port=5000 caps='application/x-rtp,encoding-name=(string)JPEG' ! rtpjpegdepay ! jpegdec ! videoconvert ! autovideosink  # MJPEG
> gst-launch-1.0 udpsrc port=5000 caps='application/x-rtp,encoding-name=(string)H264' ! rtph264depay ! avdec_h264 ! videoconvert ! autovideosink  # H.265
> gst-launch-1.0 udpsrc port=5000 caps='application/x-rtp,sampling=(string)YCbCr-4:2:0,width=(string)1920,height=(string)1012,depth=(string)8' ! rtpvrawdepay ! videoconvert ! autovideosink  # RAW
# emmm 一个麻烦的事情是,如果要用 rtp 传 raw stream,你要想办法知道这个 rtp 的属性
# 可以在发送端调高 GST_DEBUG 来判断 video/x-raw 的属性,但是有时候需要一些额外的知识
# 比如说我用来测试的视频里面的像素格式是 "I420",它对应的 rtp sampling 是 "YCbCr-4:2:0"
# 这是一个大坑点,不去搜资料是很难知道的

结果……emmmm……(视频来源

MJPEG,没错不是你这里卡住了

H.264,不少 artifacts

直接传会出现大量的细线

TCP 是没有这个问题的。有经验的读者应该能猜到是什么问题了,不过既然 UDP 在 lo 上有问题,那么我们就需要检查相关的丢包情况了。以 MJPEG 为例,观察 /proc/net/udp,可以发现在卡顿的时候是有丢包的情况的:

> cat /proc/net/udp
   sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode ref pointer drops
 2609: 00000000:1388 00000000:0000 07 00000000:00000000 00:00000000 00000000  1000        0 696929 2 0000000057310d4f 11233
 # 这一行最后一个数值不断增长。以下省略

搜索可以发现需要增加接收端的缓冲区大小:

> sysctl net.core.rmem_max
net.core.rmem_max = 212992
# 修改到一个更大的值
> sudo sysctl -w net.core.rmem_max=10485760
net.core.rmem_max = 10485760

并且接收端的 udpsrc 也需要做配置:

# 类似于这样
> gst-launch-1.0 udpsrc port=5000 buffer-size=10485760 caps='application/x-rtp,encoding-name=(string)JPEG' ! rtpjpegdepay ! jpegdec ! videoconvert ! autovideosink

(至于定多少就需要自己慢慢调了)

x11docker 的使用

x11docker 需要一段时间自己摸索,并且为了好用,桌面环境的容器需要在 x11docker 已有的容器基础上自己打包(一个可供参考的 Dockerfile)。

然后 x11docker 需要自己选一个 X server 的实现。尽管官方文档推荐 Xephyr,但是实际上根据我本地的测试,它对 XVideo 扩展的支持相当差,xvimagesink 无法正常播放视频。有可能这也是 x11docker 把 xephyr 的 XVideo 关了的原因。

令人惊讶的是,用 weston 里面跑一个 xwayland 的方式出人意料地不错(唯一的缺点是不能调 weston 的窗口大小),XVideo 是正常的,里面的桌面环境跑起来也没有问题。尽管 x11docker 默认会启动一个 X 后端的 weston(很奇怪,而且我这里鼠标移动会出问题),不过直接改 x11docker 脚本,强制 weston 以 wayland 后端启动就能缓解。

所以为什么这里 xwayland 就能让它成功屏幕共享?我的推测是,xwayland 在正常使用的时候都是以 rootless 模式启动的 (Run Xwayland rootless, so that X clients integrate seamlessly with Wayland clients in a Wayland desktop),这个模式下可能是无法正常获取整个屏幕的信息的。而这里 xwayland 以 non-rootless 模式启动,所以屏幕的信息是完整可以被应用获取的。

最后的启动命令大概是这个样子:

x11docker --gpu -I --weston-xwayland --size=1920x1080 --home --clipboard --desktop --pulseaudio=host --webcam --sudouser -i local/wemeet-x11

不过我最后没有用这个方案,原因是某会议软件在这个环境里面拒绝启动麦克风,原因不明,我也懒得拿个 IDA 去逆向。刚好之前试过 Flatpak 版本除了屏幕共享都是正常的,所以之后我实际采用的方案可能已经能猜到了。

扭曲的最终方案

Update 1 (2023-08-25): xorg-xwayland 更新到 23.2.0 之后 rootful 模式处理大小的方式出现了变化,因此下面的内容有修改:

  1. Xwayland 需要添加一个 -fullscreen 参数,否则启动的 X 的大小不对;
  2. mutter 换成了 openbox,因为 mutter 开出来之后会自己把分辨率调到 5120x2880,然后窗口就特别小,不知道是什么原因。

Update 2 (2023-08-26): 最新版的 xorg-xwayland 做了 libdecor 的适配调整,添加了 resize 的支持,所以另一种思路是在编译 xorg-xwayland 时加上 libdecor 支持,然后扔掉 weston,直接启动 Xwayland。不过 Arch 编译的版本没有加 libdecor,所以我打了个 AUR 包自己用,加上 git 版带 GTK plugin 的 libdecor,显示的效果还挺不错。此外 openbox 修改配置也很方便,可以在它的菜单中配置在这个新的 X Display 里面可能要用到的东西。可以参考:我现在使用的启动脚本我的 openbox 配置

Standalone xwayland screenshot


所以我们可以:

  1. 先开个 nested weston(别的 wayland compositor 应该也成)
  2. 在 weston 里面开个 non-rootless 的 xwayland
  3. 在 xwayland 里面开个窗口管理器(我这里用 mutter openbox,理论上随便找一个就行)
  4. 然后也在这个 xwayland 里面开某会议软件
  5. 最后把 xdp-screen-cast 输出(ximagesink)也开在这个 non-rootless 的 xwayland 里面,而且顺便还省去了 TCP/UDP 网络栈的开销

Solution graph

之前画的流程示意图,里面可能有的组件关系不够准确,但是我懒得改了。

启动脚本很简单,其中 Xwayland 的参数是从 x11docker 抄的:

#!/bin/sh -e

# Start weston
echo "Starting weston"
weston -c $(pwd)/weston.ini --socket=wayland-114 &

sleep 3

# Start xwayland
echo "Starting Xwayland"
WAYLAND_DISPLAY=wayland-114 Xwayland :114 -ac -retro +extension RANDR +extension RENDER +extension GLX +extension XVideo +extension DOUBLE-BUFFER +extension SECURITY +extension DAMAGE +extension X-Resource -extension XINERAMA -xinerama -extension MIT-SHM +extension Composite +extension COMPOSITE -extension XTEST -tst -dpms -s off -fullscreen &

sleep 3

# Start openbox and wemeet
echo "Starting openbox"
DISPLAY=:114 openbox &

echo "Starting wemeet"
DISPLAY=:114 flatpak run com.tencent.wemeet

其中 weston.ini 的内容也是从 x11docker 抄来的:

[core]
shell=desktop-shell.so
backend=wayland-backend.so
idle-time=0
[shell]
panel-location=none
panel-position=none
locking=false
background-color=0xff002244
animation=fade
startup-animation=fade
[keyboard]
[output]
name=WL1
mode=1920x1080

显示效果如下:

Weston + XWayland + Mutter

(换成 openbox 之后长得丑一些,X server 默认的黑白背景还在,不过也不是不能用)

特别地,在里面跑的 flatpak 应用需要开启后台运行权限,否则跑了几秒之后就会被杀掉。

这个方案的另一个问题是:剪贴板不能直接交互, 以及输入法也用不了了 输入法能用,不过 popup 位置有时候不对。一个 workaround 是用 剪贴板代替输入法 xclip 替代剪贴板操作:

echo -n "不是不能用" | xclip -selection clipboard -i -display :114
# 从 :0 的剪贴板复制到 :114
xclip -selection clipboard -o -display :0 | xclip -selection clipboard -i -display :114
# 从 :114 的剪贴板复制到 :0
xclip -selection clipboard -o -display :114 | xclip -selection clipboard -i -display :0

总结

我用这个方案开过了几次会,还挺舒服的,相比于开个 VM 也更流畅。

其实如果某会议软件搞个 Web 版本就没这么多破事了。