移动端音视频开发踩过的坑

1、DOM 同时监听 touchendmousedown 会依次触发 touchend -> mousedown

最小复现 Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<body>
<div style="width: 100px; height: 100px; background-color: red;"></div>
<script>
var div = document.querySelector('div');

function onClick(evt) {
console.log(evt.type);
}

div.addEventListener('mousedown', onClick);
div.addEventListener('touchend', onClick);
</script>
</body>
</html>

在最开始的设想中,是希望 <div> 在 PC 端点击时,仅触发 mousedown 的回调;在移动端点击时,仅触发 touchend 的回调;

但实际上,在移动端,如果 <div> 同时监听了 touchendmousedown 事件,点击时会依次执行 touchendmousedown 的回调函数;

想要在移动端点击时触发 touchend,我们需要在 touchend 的回调中调用 e.preventDefault(),来阻止浏览器的默认行为:

1
2
3
4
5
6
function onClick(evt) {
evt.preventDefault();
console.log(evt.type);
}

div.addEventListener('touchend', onClick);

2、HTMLMediaElement.fastSeek()HTMLMediaElement.currentTime 精度不一致

在使用 <video> 播放点播视频时,我们可以通过 HTMLMediaElement.currentTime 来改变播放进度,其使用方式一般为:video.currentTime = 10.5

除此之外,Safari 和 Firefox 还提供了 HTMLMediaElement.fastSeek() 方式来快速跳转进度,其使用方式为:video.fastSeek(10.5)

HTMLMediaElement.fastSeek()HTMLMediaElement.currentTime 两者的区别在于:HTMLMediaElement.fastSeek() 在跳转进度时并不是严格跳转到所设置的时间点(该 API 为了能快速改变播放进度,跳转的时候可能会丢失一些精度),如:

1
2
3
4
5
video.addEventListener('seeked', () => {
console.log(video.currentTime) // 跳转后的 currentTime 可能是 10.000
});

video.fastSeek(10.320); // 期望跳到 10.320

关于 HTMLMediaElement.fastSeek() 的跳转精度问题,MDN 文档中是这样描述的:

The HTMLMediaElement.fastSeek() method quickly seeks the media to the new time with precision tradeoff.

HTMLMediaElement.fastSeek() 并不是我们常用的改变播放进度的方式,之所以提到它,是因为在 video.js 的实现中,如果是在 Safari 下拖动播放器进度条,Html5 Tech 就会使用 HTMLMediaElement.fastSeek() 方式来设置播放进度;

正如上面的例子所示,使用 HTMLMediaElement.fastSeek() 设置播放进度,由于精度可能会被丢弃,播放器的进度条在显示的时候可能会出现”回跳“的现象。

3、Safari - Element 级别的自动播放策略

原文:https://webkit.org/blog/7734/auto-play-policy-changes-for-macos/

Auto-play restrictions are granted on a per-element basis. Change the source of the media element instead of creating multiple media elements if you want to play multiple videos back to back (or play a pre-roll ad with sound, followed by the main video).

我们可能知道,想要在 Chrome 有声音的去自动播放一个有音轨的视频,一般要求用户先和页面进行交互(比如用户点击过页面),然后我们才可以愉快的调用 video.play()

而且,只要用户和页面(Document)交互过,后续我们动态创建的媒体元素(<video autoplay><audio autoplay>)就都可以自动播放;

因此,我们可以认为,Chrome 的这种交互后才能自动播放的策略是页面(Document)级别的 —— 只要和页面交互过,后续该页面的所有媒体元素就都可以自动播放;

然而,Safari 的自动播放策略则是针对单个媒体元素生效的(元素级别):

  • 用户和一个媒体元素有过交互后,该媒体元素后续无论如何改变播放源(src),都可以自动播放
  • 但如果再新创建一个媒体元素,该媒体元素还是无法自动播放,仍需要用户交互

这就意味着,如果我们想要在 Safari 中连续/切换播放多个视频时保持自动播放,我们就应该尽可能复用同一个媒体元素(<video><audio>),而不是不断去替换媒体元素 ———— 在使用 React 或 Vue 开发时我们应该注意这一点。

4、iOS Safari 不支持设置音量

在 iOS Safari 中是无法通过 HTMLMediaElement.volume 来设置音量大小的,该表现在 MDN文档 中也有所描述:

iOS Safari 通过 HTMLMediaElement.volume 设置的音量大小并不会在媒体元素上生效

iOS Safari 通过 HTMLMediaElement.volume 获取到的音量大小值永远是 1

在 iOS Safari 中,我们能修改的是 HTMLMediaElement.muted,切换媒体元素的静音状态。

最小复现 Demo(在 iOS 中打开):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
<video controls autoplay></video>
<script>
var video = document.querySelector('video');
video.src = '';

video.addEventListener('play', () => {
setTimeout(() => {
video.volume = 0.2; // 3s 后尝试将音量大小修改为 0.2
}, 3000);

setTimeout(() => {
console.log('volume:', video.volume); // 5s 后打印当前音量大小,返回的应该还是 1
}, 5000);
});
</script>
</body>
</html>

电影发展简史

在我们的生活中,电影和电视为我们提供了丰富多样的娱乐选择,但我们是否知道它们哪一个更早出现?

电影比电视更早出现。

在1878年,摄影师迈布里奇接受了一项实验委托,拍摄《运动中的马》,目的是观察马在奔跑时四只腿是否会同时离开地面;在迈布里奇的帮助下,他利用相机连续拍照技术,将多张照片按时间顺序生成了一条连贯的照片带,最终确定奔跑的马在某个瞬间会有四腿同时离地的状态。这一结论打破了早期的观念,即马在奔跑时至少有一腿接触地面。

在这之后,有人将迈布里奇制作的照片带快速牵动,然后发现照片带中每张静止的马竟然“活”了起来,这件事引起了巨大轰动,并被迅速传开。他的这项研究对于摄影和运动的理解产生了重大影响,被认为是早期电影的先驱之一。

在1888年,法国发明家路易·普林斯拍摄了现今已知最早的短片——《朗德海花园场景》,影片时长只有2.11s。

黑白无声电影

1895年,兄弟雅克和奥古斯特·卢米埃尔(Lumière brothers)在法国发明了光影放映机,展示了世界上第一部商业化的电影。随后,黑白无声电影开始流行起来,包括乔治·梅利埃斯(Georges Méliès)的幻想电影。

为了提升观众体验,一些放映会伴随着管弦乐团的演奏家们演出、剧院风琴演奏、现场音效和来自表演人员或者放映员的解读。

有声电影

1927年,华纳兄弟公司(Warner Bros.)推出了第一部有声电影《爵士歌王》(The Jazz Singer),这标志着有声电影时代的开始。有声电影的出现大大改变了电影产业,并引发了一系列技术和艺术的创新。

彩色电影

20世纪30年代,彩色电影开始兴起。通过不同的技术和处理方法,电影可以以彩色形式呈现,增加了观众的视觉享受。

宽银幕和3D电影

20世纪50年代和60年代,宽银幕和3D电影开始流行。宽银幕技术提供了更广阔的画面,增强了电影的视觉冲击力。而3D技术则使观众能够获得更加身临其境的观影体验。

了解 flv

一、Web 如何播放 MP4?

二、如何在 Web 播放 flv?

三、了解 flv 格式

玩转 flv.js

1. 如何极致实践低延迟?

2. 如何优雅断线重连?

3. 如何同时播放多路flv?

如何分析 flv

1. 保留现场

在定位 flv 直播问题时(由于直播场景的特殊性,出问题的流可能转瞬即逝),我们首先需要将流保存下来,以供后续的问题定位和修复验证;

我们可以在命令行中使用 curl 命令,将 flv 流以文件形式保存下来:

1
curl https://xxx.com/test.flv -o test.flv

2. 问题初判

在对保存下来的 flv 文件深入分析之前,我们可以使用如 ffplay, ffprobe 等工具初步分析/播放一下 flv 文件,看看这些工具能否给我们提供一些有用的信息:

2.1 使用 ffplay

1
ffplay -hide_banner -autoexit -loglevel level -report -i test.flv
  • -hide_banner 在控制台中不打印 ffplay 的 banner 信息
  • -autoexit 播放结束后自动关闭播放窗口
  • -loglevel level 在控制台和日志文件输出日志时,带上日志等级(如:[error], [warning], [debug], [verbose]
  • -report 输出日志文件
  • -i 后面接视频文件的相对路径 or 绝对路径

更多参数:https://ffmpeg.org/ffplay.html

2.2 使用 ffprobe

1
ffprobe -loglevel level -hide_banner -unit -show_frames -select_streams v -show_error -of ini -i test.flv > test.ini 2>&1
  • -unit 输出的信息中带上单位
  • -select_streams v 只查看视频轨(v)信息
  • -show_frames 查看每一帧信息
  • -show_error 打印错误
  • -of ini.ini格式输出结果
  • > test.ini 2>&1 使用管道符将结果及错误信息都输出到 test.ini 文件中

更多参数:https://ffmpeg.org/ffprobe.html

3. flv分析

ffplay 和 ffprobe 只能告诉我们哪个时间点,或者哪一帧出了问题,但具体是什么问题还是需要我们深入分析 flv 流/文件;

此处推荐使用 flvAnalyser,一个可视化的 flv 分析工具;该工具支持 H.264 和 H.265,可以查看到具体的 nalu;

如何压测流媒体服务器?

介绍一些用于压测流媒体服务器的工具:

st-load

SRS是一个开源的实时视频服务器,它可以支持RTMP、WebRTC、HLS、HTTP-FLV、SRT等多种实时流媒体协议;

SRS的作者为了能更好的验证SRS服务器的流媒体转发性能,同时提供了专门的测试套件 st-load

该套件中提供了:

  • sb_hls_load 支持HLS点播和直播
  • sb_http_load 支持HTTP负载测试,所有并发重复下载一个http文件
  • sb_rtmp_load 支持RTMP流播放测试,一个进程支持5k并发
  • sb_rtmp_publish 支持RTMP流推流测试,一个进程支持500个并发

该套件需要在 linux 下运行,它会模拟客户端行为拉流。

使用方法

Build from source, then run RTMP benchmark:

1
2
3
git clone https://github.com/ossrs/srs-bench.git &&
cd srs-bench && ./configure && make &&
./objs/sb_rtmp_load -c 1 -r rtmp://127.0.0.1:1935/live/livestream

Or directly by docker:

1
2
docker run --rm -it --network=host --name sb ossrs/srs:sb \
./objs/sb_rtmp_load -c 1 -r rtmp://127.0.0.1:1935/live/livestream

For HTTP-FLV benchmark:

1
2
docker run --rm -it --network=host --name sb ossrs/srs:sb \
./objs/sb_http_load -c 1 -r http://127.0.0.1:8080/live/livestream.flv

For HLS benchmark:

1
2
docker run --rm -it --network=host --name sb ossrs/srs:sb \
./objs/sb_hls_load -c 1 -r http://127.0.0.1:8080/live/livestream.m3u8

Or from Aliyun mirror:

1
2
3
docker run --rm -it --network=host --name sb \
registry.cn-hangzhou.aliyuncs.com/ossrs/srs:sb \
./objs/sb_rtmp_load -c 1 -r rtmp://127.0.0.1:1935/live/livestream

Note: Please use docker kill sb to stop it.

使用 ./objs/sb_rtmp_load or ./objs/sb_hls_load or ./objs/sb_http_load 去拉不同协议的流,-c 表示负载数

flazr

flazr 是 RTMP 的一个 java 实现,这个项目提供了一个流媒体服务器和相关的工具类;

该工具可以在 windows 上运行,我们可以使用它来压测 rtmp 拉流;

下载地址:https://sourceforge.net/projects/flazr/files/flazr/

JMeter

使用 JMeter 的BlazeMeter-HLS Plugin,可以压测 hls 播放

参考:https://zhuanlan.zhihu.com/p/624834220

前端音视频经验之谈(一)

前端音视频经验之谈(一)

前言

本文以 FLV 为切入点,分享笔者在前端实时音视频方面遇到并解决过的一些问题;

笔者负责过的一些业务场景:

  1. 常见直播业务
    设备端使用 RTMP 协议推流 -> (云端)流媒体服务器 -> CDN 分发 -> 播放端拉 http-flv, hls, webrtc

  2. Web 预览相机 RTSP 流
    相机 RTSP -> 流媒体(格式)转换服务(将 RTSP 转为 http-flv) -> 播放端拉 http-flv

Web 前端在上述的场景中,主要是负责播放不同协议的流媒体资源;

如何尽可能稳定播放?

前端在刚开始接触实时音视频的时候,我们首先面临的核心问题应该是:“如何稳定的去播放?”

在 Web 上播放一个 MP4 视频时,我们一般是直接依赖 Html5 提供的能力,在整个播放过程中,浏览器本身会去请求数据,然后解码、渲染、播放,基本不需要开发者介入:

1
<video src="https://demo.com/test.mp4" controls autoplay muted></video>

而在播放实时流(如 http-flv)时,前端就需要使用专门的播放器(如 flv.js, mpegts.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script src="./flv.min.js"></script>
<video id="videoEl"></video>

<script>
if (flvjs.isSupported()) {
const player = flvjs.createPlayer({
isLive: true, // 直播
type: 'flv',
url: 'https://xxx.flv'
});

player.attachMediaElement(document.getElementById('videoEl'));
player.load();
player.play();
}
</script>

flv.js, mpegts.js 这些库的实现中:

  • flv 流的加载可以基于 Fetch API 实现
  • 为了能让浏览器播放 flv 流,库开发者还需要将 flv 流 解封装 -> 转封装为 fMP4(只改变了流媒体资源的封装格式)
  • 转封装后的 fMP4 流媒体数据,需要利用 MSE API 塞进原生 <video>
  • 最终由浏览器 <video> 完成对 fMP4 的解码、渲染、播放

在大多数时候,我们都是作为 flv.js, mpegts.js 这些库的使用方;

以直播业务为例,前端一般会得到一个 http-flv 流地址,然后使用 flv.js 来播放;

但这个过程并不会是一帆风顺的,在实际播放一个实时的 http-flv 时,前端可能会遇到以下情况:

  1. 播放端刚开始播放的时候,http 请求一直在 pending

前端可观察到的现象:

Network 面板中的 fetch 请求一直在 pending,浏览器需要等待 10s+ 才会抛出超时/访问失败的错误

前端实际可获得的信息

浏览器超时后,fetch() 会抛一个错误,flv.js 会抛出一个错误事件

造成此问题可能的原因:

播放端无法访问流地址(如在公网播放内网的flv流);

设备端未推流(播放端和流媒体服务建立了 TCP 连接,但一直没收到数据);

一些优化:

在原有的 flv.js 实现中,通过 fetch() 请求 http-flv;当真的出现在公网播放内网的flv流的情况时,用户需要等待10s+才能得到响应;

为了提高用户体验,我们增加了一个 xhrTimeout 配置,由前端去指定超过多长时间没收到数据就抛出超时错误,提高响应速度;

  1. http 响应返回了 404 等非 200 OK 的状态码

前端可观察到的现象:

http 请求能正常响应,但状态码错误

前端实际可获得的信息

flv.js 会判断状态码是否为 200,遇到异常错误码会抛出一个错误事件

造成此问题可能的原因:

设备端未推流;
设备端推流地址错误;
设备端无法访问流媒体服务;
流媒体服务异常;

  1. 播放突然中断 - http 连接断开

前端可观察到的现象:

Network 面板中的 http 连接断开

前端实际可获得的信息

fetch() 会抛一个错误,flv.js 会抛出一个错误事件

造成此问题可能的原因:

播放端网络中断;
播放端网络发生了切换(wifi <-> 4G);
设备端推流中断;
流媒体服务异常;

  1. 播放突然中断 - http 连接正常

前端可观察到的现象:

Network 面板中的 http 连接正常

前端实际可获得的信息

<video> 抛出错误事件;
MSE 抛出错误,flv.js 会抛出一个错误事件;

造成此问题可能的原因:

流异常,浏览器无法兼容,解码失败;

情况1、2一般发生在刚开始尝试拉流,情况3、4一般发生在拉流播放过程中;

前端在播放实时流时,在遇到上述 2、3、4 情况时,应该做到“尽可能重试”;

// TODO:


下面会介绍一些非网络因素引起的问题:

音画不同步的可能原因?

前置知识:

音频流由一帧一帧的音频帧(一段段的小音频)组成,根据音频的采样率和音频编码格式,可以算出音频帧的播放时长:
音频的采样率为 48000Hz,表示音频设备 1s 采集 48000 个样本;
音频以 AAC 编码,那么一个 aac 音频帧则会包含 1024 个样本,所以一个 aac 音频帧记录了 1024 * 1000 / 48000 = 21.33ms 的可播放音频;我们可以将 21.33ms 称为标准音频帧时长;

为了能让音频、视频有序且同步播放,一般每个音频帧、视频帧都会有各自的一个 DTS、PTS;
对于音频帧而言,一般是立即解码、立即播放的,所以它的 DTS 一般等于 PTS;

在理想情况下,音、视频的时间戳都应该是严格单调递增的;以上述 48000Hz AAC 音频为例,每一个音频帧应该以 21.33 ms 递增,考虑到时间戳一般以整数形式存储,音频帧以 21ms、22ms 来递增也是合理的;

遇到过一个问题:用户在 Web 观看 flv 直播流时,累计播放一段时间后,出现了音频快于视频画面的现象;在 VLC 上播放则不会出现这样的问题;

我们会发现,音频流的时间戳并不是严格递增的,某些音频帧之间的时间间隔大于 21.33ms;

对于这种音频存在Gap的情况,如果在 Chrome 浏览器中不做处理,直接塞给 <video> 播放,Chrome 会把后续的音频向前推进/播放,以便把Gap抹掉;

那么就会出现如下的情况,一帧应该在第95ms播放的音频被推进到第77ms播放;

由于音频帧的单位是毫秒级别,单单一两帧(少量)的音频帧并不会引起明显的音画不同步;

但如果此问题大量出现,那么随着播放时长的增加,声音快于视频的表现就会愈发明显;

这也就是为什么刚开始音画同步,播放一段时间后音画不同步。

解决方案:

flv.js 本身就有考虑到这种音频存在 Gap 的情况(最新版本默认开启配置 fixAudioTimestampGap: true),当发现 Gap >= 3 * 标准音频帧时长 时,flv.js 会主动填充静音帧,以保证音频帧按照时间戳播放。

卡顿的可能原因?

卡顿的现象:

1、画面在 loading

2、帧率不够,出现跳帧

如何正确使用 flv.js 实现低延迟?

网上给出的 flv 延迟数据一般在 2s,但在实际直播拉流时,延迟可能会到 4-6s,这可能是因为我们并没有正确使用 flv.js

1、关闭 enableWorker worker 配置

开启 worker 会提高延迟

2、关闭 enableStashBuffer: false 下载缓冲区配置

通过设置 enableStashBuffer: false,我们可以关闭 flv.js 的下载缓冲区;

一般来说,通过 fetch 请求到的流数据,会先存放在一个 ArrayBuffer 里面,等它满了才会塞进 <video> 播放;

关闭了下载缓冲区,请求到的流数据就会马上塞进 <video>,从而能降低该缓冲区带来的延迟;

风险:设立下载缓冲区是为了应对网络抖动(network jittering)的情况,关闭后可能会提高卡顿率

建议:纯内网情况下网络抖动的风险较低

3、开启 liveBufferLatencyChasing: true 跳帧配置

除了 ArrayBuffer 这个下载用的缓冲区,<video> 本身也有自己的播放缓冲区,通过 video.buffered 可以查看当前缓冲了的范围;

通过设置 liveBufferLatencyChasing: true,我们可以开启跳帧功能;

在检测到播放缓冲区过大时,跳过一部分媒体流,直接播放较新的内容,从而缩小播放画面和最新流画面之间的延迟;

npm 命令速记

  • 查看 npm 包所有版本
1
npm view <package_name> versions
  • 在本地打出一个 .tgz
1
npm pack

加上 --dry-run 参数的话,只会列出参与打包的文件,并不会真的生成 .tgz 文件

Web 如何排查音视频问题

MP4 播放失败

  1. 当发现 MP4 在浏览器上播放出错时,先 F12 打开 DevTools 查看 Console(控制台)面板是否出现报错;

  2. 其次,可以打开 DevTools 的 Media(媒体)面板,查看对应 <video> 播放器的 Message(消息)面板是否有告警/报错;

  1. 如果 Console 无报错,Message 无报错,说明浏览器认为流媒体文件是可以正常解码播放的;
    至于播放出来的效果,浏览器并不保证,如可能会出现音画不同步、视频画面闪烁等情况;这大概率是视频本身就有问题。

  2. 出现浏览器能正常播放,但播放效果不符合预期的问题时:

  • 使用 VLC、PotPlayer 工具也去播放对比看一下
  • 单独用一个 Tab 打开视频地址 or 把视频下载下来然后拖进浏览器播放;如果这样做还是有问题,那就大概率是视频本身的问题

FLV 播放失败

  1. 参照《MP4 播放失败》收集错误信息
  2. 如何分析 flv

WebRTC 播放失败

  1. 参照《MP4 播放失败》收集错误信息
  2. 查看 WebRTC 日志:chrome://webrtc-internals/chrome://webrtc-logs/

在 Safari 中调试媒体

https://webkit.org/blog/8923/debugging-media-in-web-inspector/

iOS webview 调试

  1. 在 iOS 设备上,依次打开 “设置” -> “Safari浏览器” -> “高级” -> 开启 “JavaScript” 和 “网页检查器”

  1. 在 Mac 设备上,显示 Safari 浏览器的 “开发” 菜单

  1. 将 iOS 设备连接到对应的 Mac 设备上,并在 iOS 设备上打开对应的 webview 页面,此时能在 Safari 浏览器的 “开发” 菜单中看到可调试的 webview 页面,选中对应的页面即可

如何调试 WebRTC / 媒体播放?

在 Safari 的网页检查器中,打开 “媒体日志记录”、“MSE日志记录”、“WebRTC日志记录” 设置

清理前端项目中未被使用到的文件

一个项目经过多次迭代后,不可避免的会残留一些未被使用到/多余的文件,人为的去分析文件的引用情况是一种费时、费力的行为;
使用 unused-files-webpack-plugin webpack 插件可以快速的找出哪些文件未被使用到。

安装:

1
npm i --save-dev unused-files-webpack-plugin

or

1
yarn add --dev unused-files-webpack-plugin

配置:

1
2
3
4
5
6
7
8
9
const { UnusedFilesWebpackPlugin } = require("unused-files-webpack-plugin");

module.exports = {
plugins: [
new UnusedFilesWebpackPlugin({
patterns: ['src/**/*.*']
}),
],
};

字幕

视频字幕

对于常见的视频(如 mp4、mkv)而言,根据字幕信息嵌入到视频中的方式,可以把字幕分为硬字幕和软字幕;

硬字幕是指字幕以图像的方式和视频画面相融合并硬编码在视频文件中,因此我们无法直接从视频中提取、编辑或删除字幕;由于硬字幕作为视频画面的一部分,因此可以保证在任何设备和任何播放器上看到的字幕都是完全一样的,不会有字体、位置等差异;想要从视频中提取出硬字幕,可以使用 OCR 从视频帧中识别字幕文本,从而生成 srt 等字幕文件。

软字幕根据字幕的加载/封装方式,可以分为内挂字幕和外挂字幕。(在部分资料中,会把内挂字幕等同于软字幕,而外挂字幕作为另一种类型)

  • 内挂字幕:一般指字幕文件和视频(音频流+视频流)一同封装在(MP4、MKV)视频文件中,播放时需要经过播放器处理解析显示。字幕文件以字幕流/字幕轨的方式存在于视频文件中,一个视频可以有多个内挂字幕(即多路字幕流),用户可通过播放器切换要显示的字幕。

  • 外挂字幕:指字幕以单独的文件格式存在,常见的类型有 srt、ass、vtt 等;在播放视频时,用户需要手动导入/打开字幕文件;在同一目录下,如果字幕文件名和视频文件名相同时,部分播放器能够自动加载字幕,否则需要手动打开。

软字幕的显示都需要播放器的支持,一般能够支持 mp4、mkv 和 webm;

  • 修改外挂字幕,只需要修改对应的字幕文件即可;
  • 修改内挂字幕,则需要使用相关工具对字幕流/字幕轨进行修改。

使用 ffmpeg 给 mp4 添加内挂字幕:

1
ffmpeg -i sintel-short.mp4 -i sintel-en.srt -c copy -c:s mov_text sintel-short-en.mp4

windows-mp4

【Windows 默认播放器,播放时无字幕】

vlc-mp4

【VLC 播放器,播放时需要选择字幕】

使用 ffmpeg 给 mp4 添加内挂字幕,并转为 mkv:

1
ffmpeg -i sintel-short.mp4 -i sintel-en.srt -c copy -c:s copy sintel-short-en.mkv

windows-mkv

【Windows 默认播放器,播放时需要选择字幕】

vlc-mkv

【VLC 播放器,播放时会自动显示字幕】

使用 ffmpeg 给 mp4 添加多个内挂字幕,并转为 mkv:

1
2
3
4
5
ffmpeg -i sintel-short.mp4 -i sintel-en.srt -i sintel-de.srt \
-map 0:v -map 0:a -map 1 -map 2 \
-c copy -c:s copy \
-metadata:s:s:0 language=eng -metadata:s:s:1 language=deu \
sintel-short-mul.mkv

windows-mkv-mul

【Windows 默认播放器,播放时可以切换字幕】

vlc-mkv-mul

【VLC 播放器,播放时可以切换字幕】

可以看到,给 mp4 添加字幕时我们使用的是 -c:s mov_text,而给 mkv 添加字幕时我们使用的是 -c:s copy;
-c:s 是 -scodec 的简写,它用于设置字幕的编码类型;
对于 mp4 而言,我们需要指定使用 mov_text 编码器去处理字幕轨/字幕流。

常见的字幕编码器(-c:s)参数:
对于 MKV:copy,ass,srt,ssa
对于 MP4:copy,mov_text(如果是给 MP4 添加字幕,只能用 mov_text)
对于 MOV:copy,mov_text

srt 字幕

srt 是一种比较流行的文本字幕格式,可以使用系统自带的文本编辑器来打开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1
00:00:00,000 --> 00:00:12,000
[Test]

2
00:00:18,700 --> 00:00:21,500
This blade has a dark past.

3
00:00:22,800 --> 00:00:26,800
It has shed much innocent blood.

4
00:00:29,000 --> 00:00:32,450
You're a fool for traveling alone,
so completely unprepared.

H5 视频字幕

H5 视频字幕与普通的视频字幕相类似,也可以分为硬字幕和软字幕;硬字幕依然是已经将字幕内容编码在视频画面中,使得我们在不同的浏览器、不同的设备(移动端、PC端)能看到一样的效果。

H5 并不会从 mp4 视频中解析并显示内挂字幕,但它支持通过使用 <track> 元素为视频指定外挂字幕;外挂字幕的文件格式为 WebVTT,其文件名后缀一般为 .vtt,其 MIME 类型为 text/vtt

在 Chrome 和 Firefox 浏览器下,.vtt 字幕是可以无障碍加载显示的,但是对于IE10+浏览器,虽然也支持.vtt字幕,但是需要定义 MIME type,否则会无视WebVTT格式。

<video>添加多个字幕:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<video id="video" preload="metadata">
<source src="video/sintel-short.mp4" type="video/mp4" />
<!-- 英语(因为设置了 default 属性,所以会默认启用英语字幕) -->
<track
label="English"
kind="subtitles"
srclang="en"
src="subtitles/vtt/sintel-en.vtt"
default=""
/>
<!-- 德语 -->
<track
label="Deutsch"
kind="subtitles"
srclang="de"
src="subtitles/vtt/sintel-de.vtt"
/>
<!-- 西班牙语 -->
<track
label="Español"
kind="subtitles"
srclang="es"
src="subtitles/vtt/sintel-es.vtt"
/>
</video>

multi-track

WebVTT 字幕

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
WEBVTT

0
00:00:00.000 --> 00:00:12.000
<v Test>[Test]</v>

NOTE This is a comment and must be preceded by a blank line

1
00:00:18.700 --> 00:00:21.500
This blade has a dark past.

2
00:00:22.800 --> 00:00:26.800
It has shed much innocent blood.

3
00:00:29.000 --> 00:00:32.450
You're a fool for traveling alone,
so completely unprepared.

4
00:00:32.750 --> 00:00:35.800
You're lucky your blood's still flowing.

可以看到,vtt 字幕的文本格式和 srt 字幕相似;其中,WebVTT 字幕须以 WebVTT 开头,以表明这是 WebVTT 字幕。

使用 ffmpeg -i input.srt output.vtt 可以将 srt 文件转为 vtt 文件

此外,在CSS中提供了专门的伪元素::cue来控制字幕的样式,可以控制的 CSS 属性包括:

  • color
  • opacity
  • visibility
  • text-decoration
  • text-shadow
  • background 及相关属性
  • outline 及相关属性
  • font 及相关属性,包括 line-height
  • white-space

因此,我们可以通过修改::cue伪元素样式,来控制 H5 <video> 字幕样式;

但是,原生的字幕样式控制,由于CSS属性上的限制,我们不能修改字幕出现的位置、实现更复杂的排版等,我们是否可以对字幕有更全面的样式控制?

在 video.js 中,提供了 TextTrackDisplay 组件来显示字幕;它将字幕内容渲染在 div 中,并以绝对定位的方式覆盖到视频上,使得我们可以修改所有的 CSS 属性。

缺点:部分移动端浏览器(如 iOS 浏览器、部分华为系统浏览器),全屏后会被系统劫持播放器样式,导致自定义播放器组件不显示,引起字幕不显示的问题;因此,在 video.js 的字幕实现中,会根据 user-agent 选择使用原生或 TextTrackDisplay 组件来显示字幕。

node.js 图片压缩

1
npm install -D images imagemin imagemin-mozjpeg imagemin-pngquant slash get-all-files
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import images from 'images';
import imagemin from 'imagemin';
import imageminMozjpeg from 'imagemin-mozjpeg';
import imageminPngquant from 'imagemin-pngquant';
import fsPromises from 'fs/promises';
import path from 'path';
import convertToUnixPath from 'slash';
import { getAllFiles } from 'get-all-files';

const handleFile = async (sourcePath) => {
// 如果图片 width > 750,等比缩小图片宽高
if (images(sourcePath).width() > 375 * 2) {
const fileType = path.extname(sourcePath).replace('.', '').toLowerCase();

const buffer = await imagemin.buffer(
images(sourcePath)
.size(375 * 2) // 调整图片的宽度,高度会等比缩小
.toBuffer(fileType),
{
plugins: [
imageminMozjpeg({ quality: 70 }),
imageminPngquant({
quality: [0.6, 0.8],
}),
],
}
);

const destinationPath = path.join('./build', path.basename(sourcePath).toLowerCase());

await fsPromises.mkdir(path.dirname(destinationPath), { recursive: true });

await fsPromises.writeFile(destinationPath, buffer);
return;
}

// --- 纯粹的图片大小压缩 ---
await imagemin([sourcePath], {
destination: 'build', // 结果输出到 build 目录
plugins: [
imageminMozjpeg({ quality: 70 }), // 压缩 jpg
imageminPngquant({
quality: [0.6, 0.8],
}), // 压缩 png
],
});
};

// 递归获取 images 目录下所有文件
getAllFiles('./images')
.toArray()
.then((paths) => {
return paths
.map((it) => convertToUnixPath(it)) // 将 Windows 的 "\\" 路径转为 "/"
.filter((it) => {
const ext = path.extname(it).toLowerCase();

return ['.jpg', '.jpeg', '.png'].includes(ext); // 筛选出 .jpg, .jpeg, .png 后缀的文件
});
})
.then((paths) => {
return paths.map(async (p) => {
try {
return await handleFile(p);
} catch (err) {
throw err;
}
});
});

UmiJS

图片压缩

打包时使用 image-webpack-loader 压缩图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { defineConfig } from 'umi';

export default defineConfig({
chainWebpack(memo, { env, webpack, createCSSRule }) {
// 在默认的 images 规则上,添加 image-webpack-loader 来压缩图片
memo.module
.rule('images')
.use('image-webpack-loader')
.loader(require.resolve('image-webpack-loader'))
.options({
options: {
bypassOnDebug: true, // webpack@1.x
disable: true, // webpack@2.x and newer
},
});
},
});

Webpack 杂记

  1. 关于为什么使用 await import('@/common/themes/' + window.mode) 时,会把 @/common/themes 目录下的其他文件(如,markdown 文件)也打包进 chunk!

根据 webpack官方文档 的解释:

当我们在使用 import()require() 时,若其标识符参数中含有表达式,由于这种引入形式不能在打包编译阶段确定标识符参数的值,导致无法做静态分析、构建依赖图,因此会自动创建一个上下文(context);

webpack 会解析require()调用中的字符串,提取出如下的一些信息,供 context 使用:

1
2
3
4
从 '@/common/themes/' + window.mode 中提取出:

Directory:@/common/themes
Regular expression: /^.*/

而后,webpack 会将目录(@/common/themes)下所有符合正则表达式(/^.*/)的模块都打包到一起;因此这种方式,可能会引入一些不必要的模块。

对于require()的引入方式,可通过设置require.context()解决;对于import()方式,可以使用 webpack 的内联注释解决该问题。

  1. less webpack 配置

使用 less-loader 预编译 *.less 文件,而后使用 cssnano postcss 插件压缩 css,使用 autoprefixer 插件兼容各浏览器 css prefix。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.less$/,
exclude: /node_modules/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{
loader: 'postcss-loader',
options: {
postcssOptions: process.env.NODE_ENV === 'production' ? {
plugins: [
require('cssnano')({
preset: 'default',
}),
'autoprefixer'
]
} : undefined,
}
},
{ loader: 'less-loader' }
]
}
]
}
};

二分查找

基本的二分搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// nums 递增
let binarySearch = function (nums, target) {
let left = 0;
let right = nums.length - 1;

while (left <= right) {
let mid = left + ((right - left) >> 1);

if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}

return -1;
};

因为我们是要查找一个数存不存在,所以当 left == right 时,我们也应该继续执行 while 查找。

寻找左侧边界的二分搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let searchLeftBound = function (nums, target) {
if (nums.length == 0) return -1;
let left = 0;
let right = nums.length;

while (left < right) {
let mid = left + ((right - left) >> 1);

if (nums[mid] == target) {
right = mid;
} else if (nums[mid] > target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
}
}

return nums[left] == target ? left : -1;
};

对于 nums = [1, 2, 2, 2, 3]target = 2 而言,left 的结果为 1,可以理解为 nums 中小于 2 的个数有 1 个。
target 大于 nums 所有数时,left 的结果为 nums.length;此时 nums[left]nums[nums.length]undefined,必然不等于 target

寻找右侧边界的二分搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let searchRightBound = function (nums, target) {
let left = 0;
let right = nums.length - 1;

while (left <= right) {
let mid = left + ((right - left) >> 1);

if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
}
}

if (right < 0 || nums[right] != target) return -1;

return right;
};

nums 为非递减顺序时,right < 0 表示 target 比所有元素都小,即算法不断在执行 right = mid - 1

JavaScript 算法常用语法

  1. 除2取整
1
2
3
4
let num = 5;

// let res = Math.floor(num / 2);
let res = num >> 1;
  1. 拼接字符串 String.prototype.concat()
1
2
3
4
5
6
7
8
var s1 = 'abc';
var s2 = 'def';

s1.concat(s2) // "abcdef"
s1 // "abc"

// 可以接受多个参数
'a'.concat('b', 'c') // "abc"
  1. 截取子字符串 String.prototype.slice()

slice 方法用于从原字符串取出子字符串并返回,不改变原字符串。它的第一个参数是子字符串的开始位置,第二个参数是子字符串的结束位置(不含该位置)。

1
'JavaScript'.slice(0, 4) // "Java"

如果省略第二个参数,则表示子字符串一直到原字符串结束。

1
'JavaScript'.slice(4) // "Script"

如果参数是负值,表示从结尾开始倒数计算的位置,即该负值加上字符串长度。

1
2
3
'JavaScript'.slice(-6) // "Script"
'JavaScript'.slice(0, -6) // "Java"
'JavaScript'.slice(-2, -1) // "p"
  1. Unicode 字符串比较

字符串按照字典顺序进行比较。

1
2
3
4
5
6
7
'a' > 'z' // false

'abc' < 'abd' // true

'abc' < 'abcd' // true

'a0b1' < 'a1b1' // true
  1. 截取子数组 Array.prototype.slice()

slice 方法用于提取目标数组的一部分,返回一个新数组,原数组不变。
第一个参数为起始位置(从0开始),第二个参数为终止位置(但该位置的元素本身不包括在内)。
如果省略第二个参数,则一直返回到原数组的最后一个成员。

1
2
3
4
5
6
7
var a = ['a', 'b', 'c'];

a.slice(0) // ["a", "b", "c"]
a.slice(1) // ["b", "c"]
a.slice(1, 2) // ["b"]
a.slice(2, 6) // ["c"]
a.slice() // ["a", "b", "c"]

如果 slice 方法的参数是负数,则表示倒数计算的位置。

1
2
3
var a = ['a', 'b', 'c'];
a.slice(-2) // ["b", "c"]
a.slice(-2, -1) // ["b"]
  1. 删除原数组元素 Array.prototype.splice()

splice 方法用于删除原数组的一部分成员,并可以在删除的位置添加新的数组成员,返回值是被删除的元素。注意,该方法会改变原数组。

splice 的第一个参数是删除的起始位置(从0开始),第二个参数是被删除的元素个数。如果后面还有更多的参数,则表示这些就是要被插入数组的新元素。

1
2
3
var a = ['a', 'b', 'c', 'd', 'e', 'f'];
a.splice(4, 2) // ["e", "f"]
a // ["a", "b", "c", "d"]
  1. 数组排序 Array.prototype.sort()

sort 方法对数组成员进行排序,默认是按照字典顺序排序。排序后,原数组将被改变

1
2
3
4
5
var arr = ['d', 'c', 'b', 'a'];

arr.sort(); // ['a', 'b', 'c', 'd']

arr // ['a', 'b', 'c', 'd']
  • number[] 升序排序
1
numbers.sort((a, b) => a - b);
  • number[] 降序排序
1
numbers.sort((a, b) => b - a);
  1. Array.prototype.reverse()

reverse 方法用于颠倒排列数组元素,返回改变后的数组。注意,该方法将改变原数组

1
2
3
4
var a = ['a', 'b', 'c'];

a.reverse() // ["c", "b", "a"]
a // ["c", "b", "a"]
  1. 合并数组 Array.prototype.concat()

concat 方法用于多个数组的合并。它将新数组的成员,添加到原数组成员的后部,然后返回一个新数组,原数组不变

1
2
3
4
5
6
7
let arr = ['hello'];

arr.concat(['world'], ['!']) // ["hello", "world", "!"]

arr // ['hello']

[2].concat({a: 1}, {b: 2}) // [2, {a: 1}, {b: 2}]
  1. 易混淆知识点
  • inhasOwnProperty
1
2
3
4
5
var obj = {};

'toString' in obj // true

obj.hasOwnProperty('toString') // false
  • typeof
1
2
3
4
5
6
7
typeof 1 // 'number'
typeof false // 'boolean'
typeof 'abbc' // 'string'
typeof [] // 'object'
typeof null // 'object'
typeof function() {} // 'function'
typeof undefined // 'undefined'