美文网首页
如何优雅地实现网页播放视频

如何优雅地实现网页播放视频

作者: yuanzicheng | 来源:发表于2021-06-07 14:29 被阅读0次

    技术选型:
    React & Griffith
    Python & Sanic

    1.背景

    最近公司要做一个培训系统,其中一个模块为课程学习,需求为上传及播放学习视频。

    2.简单实现

    最简单、粗暴,同时体验最差的做法就是后台直接将视频文件以bytes写入http response,同时response headers中标记media相关的属性Content-Type: video/mp4,然后丢给浏览器播放。

    这种实现对于后台来说,使用Python异步web库Sanic,函数中的具体实现只需要1行代码(查找文件路径相关的代码除外):

    from sanic.response import file_stream
    
    bp = Blueprint('videos', url_prefix="/videos")
    
    @bp.route('/<video_id>', methods=["GET"])
    async def video(request, video_id):
        # get the video location with video_id
        file_path = '/path/to/file'
        return await file_stream(file_path)
    

    3.优化思路

    好吧,对于一个有理想、有追求的程序员来说,上面的这种简单做法是绝对无法接受的。

    3.1 视频播放器

    第一步,必须搞个视频播放器,就算后台服务再垃圾,网页的UI也必须花哨一些,至少看起来像点样子。

    由于前端页面使用的React.js,经过了一番搜索,了解到知乎开源了一个视频播放库Griffith,正好适配React.js。好吧,有时候运气也很重要,现在直接就能用了,如果技术选型是Vue.js的话,就要自己再费点事进行一些包装了。

    这个视频播放库使用起来也是极为简单,无需动多少脑筋,引入组件,定义好视频URL,一个功能强大的视频播放器就能在页面呈现了,自己调整下播放器的宽高,以适应页面大小,“看起来”就完美了。

    import React from "react";
    import Player from 'griffith';
    
    export const Video = () => {
    
        const sources = {
            sd: {
                 play_url: 'https://zhstatic.zhihu.com/cfe/griffith/zhihu2018_hd.mp4'
            },
            hd: {
                 play_url: '/api/videos/001'
                 // play_url: 'https://zhstatic.zhihu.com/cfe/griffith/zhihu2018_hd.mp4'
            }
        };
    
        return (
            <React.Fragment>
                <Player sources={sources} useAutoQuality={true} />
            </React.Fragment>
        );
    };
    
    3.2 分段缓冲

    找到合适的播放器后,为什么只能说“看起来”完美呢?

    经过一番尝试,发现在使用自己后台服务提供的URL时,打开页面播放视频时,播放器总是会呈现Loading状态,非得等整个视频完全下载后才开始播放。这要是播放一个稍微大点的文件,或者网速不太得劲的时候,那我岂不是要等到天荒地老?

    对比Griffith示例提供的URL,这个视频在页面打开的瞬间就能播放,进度条也是渐进地增长,通过鼠标选定位置,还能跳跃式的播放!好嘛,这才是咱们想要的结果。

    打开浏览器的调试工具,一遍播放一遍观察网络请求,发现了一些“不对劲”的地方。它首先发起第一个请求,会在request headers中包含range: bytes=0-1,同时reponse headers中包含accept-ranges: bytes,同时HTTP请求的Status Code为206,不是正常的200。

    继续播放的过程中,又以同样的URL重新发起了多次请求,request headers中Range的参数值有所变化,看起来像是文件下载时分片下载、断点续传的思路,只不过这里是将视频文件一段一段地下载。

    到这里,基本能够理解大概的思路了:

    第一次请求时,请求头中发送range: bytes=0-1实际上是一次“试探”,后台服务收到请求后通过accept-ranges: bytes告诉客户端此文件是什么、大小是多少、能够支持文件内容部分下载。客户端收到信息后,就可以计算好每一次bytes的范围,一段一段地去下载文件内容;同时服务端也根据后续请求头中的range: bytes=start-end,将文件指定范围的bytes返回给客户端。重复这个步骤,直至整个视频文件下载完毕。

    4.具体实现

    不错,思路终于理清楚了,那么怎么实现这个逻辑呢?

    前端播放器在发起第一个请求后,就要计算下一次想要下载文件的bytes范围,并且播放到一定进度的时候,还要继续发起新的请求,这里面猜想估计比较复杂,而且还涉及到视频解析相关的知识,由于能力有限,就不研究视频播放器是怎么处理这些逻辑的了。

    把关注点放在后台服务的处理上!仔细研究了一下file_stream这个函数,其实它已经支持了按Range范围获取文件内容

    async def file_stream(
        location: Union[str, PurePath],
        status: int = 200,
        chunk_size: int = 4096,
        mime_type: Optional[str] = None,
        headers: Optional[Dict[str, str]] = None,
        filename: Optional[str] = None,
        chunked="deprecated",
        _range: Optional[Range] = None,
    ) -> StreamingHTTPResponse:
        """Return a streaming response object with file data.
    
        :param location: Location of file on system.
        :param chunk_size: The size of each chunk in the stream (in bytes)
        :param mime_type: Specific mime_type.
        :param headers: Custom Headers.
        :param filename: Override filename.
        :param chunked: Deprecated
        :param _range:
        """
        if chunked != "deprecated":
            warn(
                "The chunked argument has been deprecated and will be "
                "removed in v21.6"
            )
    
        headers = headers or {}
        if filename:
            headers.setdefault(
                "Content-Disposition", f'attachment; filename="{filename}"'
            )
        filename = filename or path.split(location)[-1]
        mime_type = mime_type or guess_type(filename)[0] or "text/plain"
        if _range:
            start = _range.start
            end = _range.end
            total = _range.total
    
            headers["Content-Range"] = f"bytes {start}-{end}/{total}"
            status = 206
    
        async def _streaming_fn(response):
            async with await open_async(location, mode="rb") as f:
                if _range:
                    await f.seek(_range.start)
                    to_send = _range.size
                    while to_send > 0:
                        content = await f.read(min((_range.size, chunk_size)))
                        if len(content) < 1:
                            break
                        to_send -= len(content)
                        await response.write(content)
                else:
                    while True:
                        content = await f.read(chunk_size)
                        if len(content) < 1:
                            break
                        await response.write(content)
    
        return StreamingHTTPResponse(
            streaming_fn=_streaming_fn,
            status=status,
            headers=headers,
            content_type=mime_type,
        )
    

    只不过在参数的传递上,缺少了_range,所以一开始的做法永远只会一次下载整个文件。那么只要从request headers中获取range,然后构造_range作为参数传递给file_stream函数,那么此函数中if _range:这部分逻辑就能够正常执行,每次请求时就会获取指定范围的bytes,而不是整个文件了,同时response header也包含了预期的信息。

    最终,后台服务代码调整如下,同样省略查找文件路径相关的代码:

    from sanic.handlers import ContentRangeHandler
    from sanic.response import file_stream
    from sanic.compat import stat_async
    
    bp = Blueprint('videos', url_prefix="/videos")
    
    @bp.route('/<video_id>', methods=["GET"])
    async def video(request, video_id):
        # get the video location with video_id
        file_path = '/path/to/file'
        stats = await stat_async(file_path)
        _range = ContentRangeHandler(request, stats)
        return await file_stream(file_path, _range=_range)
    

    到这里为止,用自己的后台服务提供的URL进行播放,效果基本与示例的效果一样了。

    5.再次优化

    那么,现在就优雅地实现了视频播放吗?并没有!

    上面的处理中,后台服务的逻辑是根据video_id去服务器本地目录查找文件,这显然是不符合实际情况的。文件存储的方案通常不会选择直接存储在服务器本地,而是选择一些对象存储的服务。

    所以上面的代码中提及的查找文件路径,再获取文件内容的逻辑就需要调整了。如此一来,file_stream这个函数就无用武之地了,因为它支持按路径读取本地文件。

    不过呢,虽然不能直接使用file_stream,咱们依然可以参照它的处理方式,按照请求头中指定的文件bytes范围,从对象存储获取相应的部分文件并返回,而不是从本地路径读取文件。

    这部分代码实际上将file_stream稍作调整就完成,这里就不贴出来了,有兴趣的可以动手实践一下!

    6.总结

    实现一个支持分段缓冲的视频播放功能,主要工作在于发起请求时,在request headers中携带Range属性,告知服务器想要获取的部分文件,服务器在接收到请求后获取Range中指定的范围,按这个范围返回文件内容。

    本文中请求相关的处理,视频播放器已经帮我们做了,而后台服务相关的逻辑,web框架也提供了一些支持,也就是file_stream这个函数,尽管只支持读取服务器本地文件。如果选择使用其他编程语言和web框架,也可以参考这部分代码自己来实现。

    相关文章

      网友评论

          本文标题:如何优雅地实现网页播放视频

          本文链接:https://www.haomeiwen.com/subject/xlnisltx.html