chromium项目开发本身是自带ffmpeg,可以自己对视频编解码、播放。
chromium本身已经提供了一套嵌入视频到网页(同层播放)的完整方案。
android应用调用android系统播放器,需要跳转到一个系统提供的Activity执行视频播放,这样做的缺点是小程序无法做到更丰富的效果,比如弹幕功能,而且用户需要手动返回才可以回到之前打开的小程序。
频同层播放方案,即视频和小程序同时显示,下图中蓝色区域是视频,粉色区域包括更下面部分是小程序的显示部分,视频上方是弹幕。
方案选型
方案一:
借助于Wayland Compositor和chromium compositor两套合成机制,将系统视频播放器的视频帧封装成chromium可以识别的纹理,参与到chromium的合成,最终渲染到屏幕。需要系统视频播放器的纹理数据可以共享到Browser端,这里想到有两种方式,一种是系统视频播放器渲染一块共享内存,chromium读取共享内存的方式获取纹理。另外一种方式是将chromium当成Wayland Compositor Service,将系统视频播放器当成一个Client,Client完成渲染后提交到Service来合成,这其实也是一种共享内存的方式,只是需要通过Wayland协议来完成。
方案二:
参考Android手机浏览器的经验,视频同层播放的另外一种方案,是将系统视频显示在页面下方,然后在页面的视频所在区域挖一个透明的洞,让底层的视频可以透出来。
方案细节
chromium是通过分层的方式渲染网页和Aura(chromium内UI框架),可以想到在引擎中挖一个洞,就是需要视频下面所有的层都挖一个大小、位置相同的洞。要实现这个功能,需要了解一下chromium渲染机制。
chromium渲染的基本概念
Layer,DOM树经过解析后最终会生成一系列cc::Layer,这些cc::Layer可能会划分成一个个Tile,Tile是chromium绘制的基本单位。
Compositor,chromium的合成器,是一个树形结构,每一个子合成器为父合成器提供一个绘制帧,最终通过根的合成器绘制到屏幕。目前web和Aura分别有一个合成器。
CompositorFrame,compositor需要绘制的一帧内容,包含一些quads和关联的纹理资源。
Quad, 绘制Layer最终就是绘制Layer里面的Tile。Tile在chromium中用一个Quad表示,它是一个四个点指定的矩形区域,包含了填充改区域需要的内容。CompositorFrame包含了一些RenderPass,一个RenderPass包含了一个Quad列表;每一个Quad触发一个GL绘制命令。绘制到屏幕前,Quad将排序,按照从屏幕下方往屏幕上方的顺序绘制。
所以,如何在chromium中挖一个透明的区域,同时可以在视频上方显示字幕,就是要找到视频所在Quad,在绘制这个Quad之前,每一个Quad在视频区域挖一个洞,然后跳过视频所在Quad,再绘制视频上方的Quad(比如弹幕)时停止挖洞。
下面介绍挖洞的原理,以及如何确定视频区域的位置和大小。
挖洞方案
默认情况下,视频未播放时会显示一个占位图,如果没有指定占位图,会显示一块黑色区域。这两种情况下,”video”标签并不会激活硬件加速,即不会创建一个单独的cc::Layer。我们的播放器实际接入的是系统的视频播放器,并不会触发”video”自身的播放,所以挖洞后,”video”标签并不会刷新,一直保持显示占位图的状态。这样导致在compoisitor中获取”video”的大小和位置非常麻烦,因为compositor只知道Layer的属性,Layer内部的状态是没法获取的。想到的方案是在播放视频时,强制给”video”设置一个SolidColorLayer,并且给它设置may_contain_video标记表示改Layer含有视频。使用SolidColorLayer是因为它比较简单,占用内存极少,不需要关联纹理。这样在compositor中可以直接获取该Layer的大小和位置也就是获取视频的大小和位置。在遍历Quad列表绘制上屏时,遇到may_contain_video为true之前,所有Layer都在大小和位置挖洞。
绘制CompositedFrame前会计算当前需要绘制的RenderPass,会遍历整个Layer树,找到may_contain_video为true的Layer就给整个CompositedFrame设置一个contain video的标记,并且保存这个Layer的大小位置。
前面说过,对于每一个quad都会执行一次gl绘制命令,以SolidColorLayerImpl为例,它是SolidColorLayer在Impl端对应的对象,绘制之前每一个LayerImpl都会调用AppendQuads添加绘制需要的Quad,SolidColorLayerImpl的Quads是以最大不超过256×256的Tile方块划分的一个或多个Tile,这么做是为了减少重绘区域大小。
对于含有视频的SolidColorLayerImpl,其实没有必要划分Tile,因为它在上屏时,实际会被跳过。
那些在含有视频的SolidColorLayerImpl下面的Quads都是通过glDrawElements命令绘制,调用glDrawElements之前都会设置一个对应的gl program,这个program使用的是同一套vertex/fragment shader代码,只是参数设置的不一样。我们给fragment shader添加几个参数:
HDR("uniform float video_frame_x;");
HDR("uniform float video_frame_y;");
HDR("uniform float video_frame_w;");
HDR("uniform float video_frame_h;");
Fragment shader中可以通过gl_FragColor设置某一个位置的颜色值,我们通过上面这四个变量设置一块区域,如果在该区域内,就把gl_FragColor设置为透明:
SRC("if (contains_video) {");
SRC("if (gl_FragCoord.x < (video_frame_x + video_frame_w)
&& (gl_FragCoord.y < video_frame_y + video_frame_h)
&& gl_FragCoord.x > video_frame_x
&& gl_FragCoord.y > video_frame_y) {");
SRC("gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);");
SRC("}}");
这样就达到了给quads挖洞的效果。
视频播放器流程
小程序的视频组件”wx-video”在触发播放时,会通过wx.publish一个videoActionChanged事件,这个事件在Browser端处理,直接触发全局视频播放器给系统视频播放发送播放命令。在视频同层播放的方案中,每一个”wx-video”组件都需要保存自己的状态,同一个Page可能有多个”wx-video”,每一个Page也可能有一个”wx-video”,这就需要将原来的单例方案调整为多实例方案。
HTMLVideoElement通过Media Factory创建WebMediaPlayer具体实例,我们可以单独为创建一个RenderVideoPlayer实例,用于接管”video”标签的状态机,并且实现与Browser端的BrowserVideoPlayer通信。BrowserVideoPlayer用来和全局唯一的VideoManager通信。VideoManager再通过IPC消息与系统的视频播放器通信。
在一个Render进程内,每一个RenderVideoPlayer拥有一个唯一ID;VideoManager的回调,通过参数绑定的方式传递webview_id和RenderVideoPlayer的唯一id,保证VideoManager的每一个回调都给到对应的RenderVideoPlayer。对于不同的Render进程,不同的PageWebView的webview_id是全局唯一的,这样保证了存在多个Render进程的情况下,VideoManager可以找到对应的RenderVideoPlayer。
wx-video组件
wx-video包含的标题、播放列表等信息不是Html “video”标签的标准属性,我们使用setAttribute这个API给”video”设置这些非标准属性,在RenderVideoPlayer中通过GetAttribute获取这些值,再通过IPC消息传递到Browser端的BrowserVideoPlayer。
弹幕
wx-video组件的结构:
<wx-video danmu-btn=””enable-danmu=””danmu-list=””src=”” id=”myVideo”><div class=”wx-video-container”><video webkit-playsinline=”” playsinline=”” src=””></video><div style=”z-index: 10000″ class=”wx-video-cover”><img src=”video_play_button.png” class=”wx-video-cover-play-button”><p class=”wx-video-cover-duration”>00:00</p></div><div style=”z-index: 9999;” class=”wx-video-danmu”></div></div><div style=”position: absolute; top: 0; width: 100%; height: 100%; overflow: hidden; pointer-events: none;”></div></wx-video>
弹幕是一个覆盖在<video>之上的class是”wx-video-danmu”的<div>标签,内部显示不同的文本。保证它的z-index大于<video>。我们遇到过视频停止播放后,播放按钮无法点击的问题,是因为弹幕覆盖在了最上面,播放按钮是class为”wx-video-cover-play-button”的<img>标签,需要保证显示播放按钮时,但是是隐藏的,或者z-index比按钮小。
全屏
全屏是通过将整个wx-video组件调用一次webkitRequstFullscreen来实现,之所以这样处理,是因为,wx-video组件内部有弹幕、控制面板,如果只是把内部的html video元素全屏,会因为webkit把html video点放到zindex最上层,导致弹幕、控制面板都被html video遮挡显示不了。
handleTapFullscreen: function (e) {……this.enableFullScreen = !this.enableFullScreen;(this.enableFullScreen ?(this.$$.webkitRequestFullscreen(),……) : (document.webkitCancelFullScreen(),……)),……},
webkitRequstFullscreen在webkit内部有个限制,就是必须由用户触发,否则会提示“API can only be initiated by a user gesture”api调用失败。这里应该是微信JS框架内部的事件处理架构导致的,事实上全屏就是用户点击全屏按钮触发的,只是到真正执行webkitRequestFullscreen时,这个用户触发的标记被抹除。所以我们在内核层面做了处理,在进入全屏时创建一个ScopedOrientationChangeIndicator对象,这个对象存在期间,内核就认为当前正在旋转屏幕,在手机设备上被认为是在切换全屏,从而绕过必须由用户触发的限制。
另外开发者可能有自定义控制面板的需求,设计自己的UI界面。微信JS框架本身是支持的,即在wx-video组件最后面,定义了一个div组件它是显示在wx-video最上层的,里面有个slot,即 wx-video内部的子节点,小程序的写法是这样:
<video>自定义UI</video>。
通过webkitRequstFullscreen进入全屏时,在这个<video>子元素中添加UI是唯一支持开发者自定义UI的方案。即使创建了一个position:fixed的元素也无法显示到全屏界面覆盖到视频上,原因如前面介绍webkitRequstFullscreen所说。
需要注意的是, wx-video组件这个div组件被设置了”pointer-events:none;”的CSS属性,即该组件和子组件都不响应touch事件,所有如果开发者自定义UI需要响应touch事件的话,需要自行加上”pointer-events:auto;”的CSS属性,这样就可以忽略JS框架内部设置的”pointer-events:none;”
坐标转换
视频挖洞方案需要解决的一个问题是需要实时获取视频节点的位置,通知到系统视频播放器。
对于一个css动画,例如transfrom,一般的流程是先解析css,然后动态计算DOM节点位置变化,再更新每一个Layer的位置,最后通知compositor更新绘制,然后再动态计算DOM节点位置,循环更新,直到动画结束。而chromium为了优化渲染效率,会在解析完css之后,在compositor中就为Layer添加一个动画,由compositor通过对Layer进行矩阵变换的方式运行动画,然后再反向通知DOM节点位置变化,再通知到javascript。
gl_renderer挖洞使用的区域,是根compositor计算出来的,调用gl命令绘制时,最终坐标是以整个Surface的左上角为原点的。那么,一个DrawQuad是怎么绘制到Surface的具体位置上的?
1)quad_layer_rect:DrawQuad自身包含的局部偏移量和大小,这个偏移量是相对于DrawQuad左上角为原点。
2)quad_to_target_transform * quad_layer_rect:quad_to_target_transform是DrawQuad绘制到目标坐标系上的矩阵,计算结果是相对于目标层级的坐标。
3)projection_matrix * quad_to_target_transform * quad_layer_rect:再乘一个投影矩阵,计算的结果是OpenGL的世界坐标系,即屏幕左上角是(-1, 1),屏幕右下角是(1, -1)
4) window_matrix * projection_matrix * quad_to_target_transform * quad_layer_rect:再乘一个窗口矩阵,左上角为原点。例如OpenGL世界坐标系中屏幕正中间为(0,0),经过该次转换变成(width/2, height/2)
5) 对于一个页面存在多个RenderPass的情况,在乘project_matrix之前需要考虑当前quad所在RenderPass的屏幕坐标,这个偏移量在按照Z-index遍历RenderPass时就会考虑进去。
另外,我们给系统发送视频的位置,是通过覆盖SolidColorLayer的SetBounds/SetPosition方案,以监听Layer大小位置的变化,然后通过blink提供的坐标转换方法转换为相对于WebView的绝对坐标,例如下面的LocalToAbsoluteQuad、ContentsToViewport等方法,本质上和compositor计算的方法是类似的。
发送给系统之前,需要转换成屏幕坐标,就是需要加上titlebar的高度。
WebRect HTMLVideoElement::Bounds() const {LocalFrameView* view = GetDocument().View();if (!GetLayoutObject()) {return WebRect();}LayoutVideo* layout = ToLayoutVideo(GetLayoutObject());LayoutRect r= layout->ReplacedContentRect();FloatQuad quad=layout->LocalToAbsoluteQuad(FloatRect(r.X().ToFloat(), r.Y().ToFloat(),r.Width().ToFloat(),r.Height().ToFloat()));IntRect result= quad.EnclosingBoundingBox();return WebRect(view->ContentsToViewport(result));}