FLV 直播流播放原理

最近一段时间在鼓捣一个视频播放项目,被 flv、hls 格式啥弄的挺懵,又想到之前还有说 B 站给 flv.js 的作者工资很低,一堆人打抱不平,就想着了解一下 flv 是怎么在浏览器中播放的。

虽然 flv 的播放细节非常多,但其实基本原理不难,很快就可以看懂,对大多数不专门做这个的同学来说,基本原理已经足够了。

FLV 是一种视频封装格式,它将音视频数据通过一种特定的格式包装了起来,我们首先来了解一下 FLV 的格式规范。

FLV 格式

FLV 视频流由 一个 Header 和若干个 Packets 组成,形如:Header | Packets | Packets | … | Packets

其中,Header 部分的字段为:

Field Data Type Default Details
Signature byte[3] “FLV” Always “FLV”
Version uint8 1 Only 0x01 is valid
Flags uint8 bitmask 0x05 Bitmask: 0x04 is audio, 0x01 is video (so 0x05 is audio+video)
Header Size uint32_be 9 Used to skip a newer expanded header

可以看到其实信息并不多,主要就是明确标记出这是一个 FLV 格式的文件。

Packets 部分的字段为:

Field Data Type Default Details
Size of previous packet uint32_be 0 For first packet set to NULL
Packet Type uint8 18 For first packet set to AMF Metadata
Payload Size uint24_be varies Size of packet data only
Timestamp Lower uint24_be 0 For first packet set to NULL
Timestamp Upper uint8 0 Extension to create a uint32_be value
Stream ID uint24_be 0 For first stream of same type set to NULL
Payload Data freeform varies Data as defined by packet type

前面的字段用来标记这个 packets 的类型、大小等。payload data 部分放的就是具体的音视频数据。

实际传输过程中,音视频的 packets 是穿插放在一起的,按照时间戳来排序。

可以看到 FLV 的数据格式设计很适合作为直播流,服务端只需要不断发送新的 Packets 部分即可。

FLV 播放基本原理

解析 FLV 数据。(demuxing)

头信息和数据包,提取视频和音频数据。示例代码:

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
class FLVParser {
constructor(arrayBuffer) {
this.data = new DataView(arrayBuffer);
this.position = 0;
}

readUint8() {
return this.data.getUint8(this.position++);
}

readUint24() {
const value = (this.readUint8() << 16) | (this.readUint8() << 8) | this.readUint8();
return value;
}

readUint32() {
const value = this.data.getUint32(this.position);
this.position += 4;
return value;
}

parseHeader() {
const signature = String.fromCharCode(this.readUint8(), this.readUint8(), this.readUint8());
if (signature !== 'FLV') {
throw new Error('Invalid FLV file');
}
const version = this.readUint8();
const flags = this.readUint8();
const dataOffset = this.readUint32();
console.log(`FLV Version: ${version}, Flags: ${flags}, Data Offset: ${dataOffset}`);
}

parseTag() {
const tagType = this.readUint8();
const dataSize = this.readUint24();
const timestamp = this.readUint24() | (this.readUint8() << 24);
const streamID = this.readUint24();

console.log(`Tag Type: ${tagType}, Data Size: ${dataSize}, Timestamp: ${timestamp}, Stream ID: ${streamID}`);

this.position += dataSize; // Skip over data for this example
}

parse() {
this.parseHeader();
while (this.position < this.data.byteLength) {
this.parseTag();
this.position += 4; // Skip previous tag size
}
}
}

// Usage example
fetch('your-flv-file.flv')
.then(response => response.arrayBuffer())
.then(data => {
const parser = new FLVParser(data);
parser.parse();
})
.catch(error => console.error('Error parsing FLV:', error));

解码音视频数据。(codec, transcoding,transmuxing)

通常为 H.264 视频和 ACC 音频数据。播放时不需要直接解码音视频数据,而是将其转封装为浏览器支持的格式。示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function parseVideoData(data) {
// 假设 data 是一个包含 H.264 NALU 的 Uint8Array
let position = 0;

while (position < data.length) {
// 查找 NALU 单元起始码 (00 00 00 01)
if (data[position] === 0x00 && data[position + 1] === 0x00 &&
data[position + 2] === 0x00 && data[position + 3] === 0x01) {
// 处理 NALU 单元
position += 4;
// 读取 NALU 类型和数据(此处省略具体处理逻辑)
} else {
position++;
}
}
}

使用 MSE 进行播放。

  1. 使用 Media Source Extensions 创建一个 MediaSource 对象。
  2. 将解码后的音视频数据添加到 SourceBuffer 中。
  3. MSE 负责将 SourceBuffer 中的数据传递给 HTML5 <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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple FLV Player</title>
</head>
<body>
<video id="video" controls width="640" height="360"></video>
<script>
const video = document.getElementById('video');
if (!window.MediaSource) {
console.error('MSE not supported');
return;
}

const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);

mediaSource.addEventListener('sourceopen', () => {
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');
fetchFLVStream(sourceBuffer);
});

function fetchFLVStream(sourceBuffer) {
// Replace with your FLV stream URL
const flvStreamUrl = 'your-flv-stream-url.flv';
fetch(flvStreamUrl)
.then(response => response.arrayBuffer())
.then(data => {
// Simplified: Directly append data
// In reality, you need to parse FLV and extract H.264/AAC frames
sourceBuffer.appendBuffer(data);
})
.catch(error => console.error('Error fetching FLV stream:', error));
}
</script>
</body>
</html>

FLV to MP4

从 FLV 播放的基本原理我们可以知道,FLV 只是一种封装格式,其中使用 Packets 携带了音视频数据,而想要在浏览器中播放,我们需要把这些音视频数据转换为浏览器支持的格式,比如 MP4。

这里我们来看一下基本原理中步骤二的细节,MP4 也是一种封装格式,这里将 FLV 转换为 MP4 并不需要去修改音视频数据,而是将封装的格式进行一些转换。落实到代码上,其实就是按照 MP4 的数据格式,拼一个出来,示例代码:

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
70
71
72
73
74
75
76
77
78
79
80
function parseFLV(flvData) {
let position = 0;

// Read FLV header
const signature = flvData.slice(position, position + 3);
position += 3;
if (signature !== 'FLV') throw new Error('Not a valid FLV file');

// Skip version and flags
position += 2;

// Skip header length
position += 4;

const tags = [];

while (position < flvData.length) {
const tagType = flvData[position];
position += 1;

const dataSize = readUInt24(flvData, position);
position += 3;

const timestamp = readUInt24(flvData, position);
position += 3;

// Skip extended timestamp and stream ID
position += 4;

const data = flvData.slice(position, position + dataSize);
position += dataSize;

tags.push({ tagType, timestamp, data });

// Skip previous tag size
position += 4;
}

return tags;
}

function createMP4(tags) {
const ftyp = createFtypBox();
const moov = createMoovBox(tags);
const mdat = createMdatBox(tags);

return concatenateBuffers(ftyp, moov, mdat);
}

function createFtypBox() {
// Create a basic ftyp box
return new Uint8Array([...]);
}

function createMoovBox(tags) {
// Create a basic moov box, including trak, mdia, minf, stbl, etc.
return new Uint8Array([...]);
}

function createMdatBox(tags) {
// Concatenate all media data from tags into mdat box
return new Uint8Array([...]);
}

function concatenateBuffers(...buffers) {
// Combine multiple Uint8Arrays into one
let totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0);
let result = new Uint8Array(totalLength);
let offset = 0;
for (let buf of buffers) {
result.set(buf, offset);
offset += buf.length;
}
return result;
}

function readUInt24(buffer, position) {
return (buffer[position] << 16) | (buffer[position + 1] << 8) | buffer[position + 2];
}

到这里我们已经了解了浏览器中播放 FLV 的基本原理,可以看到目前这种在浏览器中播放 FLV 的方式有一点 trick,浏览器并不直接支持,JavaScript 承担了一层数据转换的工作。具体来说,目前的播放方式存在以下问题:

  1. 我们需要使用 JavaScript 将 FLV 包装转换为 MP4 包装,尽管 JavaScript 已经很快了,但这种 buffer 操作还是会有负担,同时要注意这一操作是在主线程执行的,可能会影响用户的浏览(flv.js 提供了 enableWorker 选项,将 transmuxing 放在 worker 中执行,可以解决这一问题)。
  2. 其实我们只是转换了一层包装,并没有解码能力,实际的解码播放操作还是浏览器的 <video> 元素实现的。如果想要做一些更厉害的东西,比如修改某一帧,就比较难实现了。

WebCodecs API

WebCodecs API 使得我们可以直接在浏览器中处理视频流的单个帧或者音频数据块,它将编解码器能力暴露给了 Web 应用。这使得 Web 应用现在也可以完全控制媒体处理方式。将上面的例子改为使用 WebCodecs API 来实现:

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FLV Video Playback</title>
</head>
<body>
<video id="videoElement" controls></video>
<audio id="audioElement" controls style="display:none;"></audio>
<script>
async function playFLV() {
const videoElement = document.getElementById('videoElement');
const audioElement = document.getElementById('audioElement');

// Fetch and parse the FLV file
const response = await fetch('path/to/your/video.flv');
const arrayBuffer = await response.arrayBuffer();

// Parse the FLV file (you'll need a library or custom parser here)
const { videoChunks, audioChunks, audioBlob } = parseFLV(arrayBuffer);

// Set up audio element source
const audioURL = URL.createObjectURL(audioBlob);
audioElement.src = audioURL;

const mediaSource = new MediaSource();
videoElement.src = URL.createObjectURL(mediaSource);

mediaSource.addEventListener('sourceopen', () => {
const videoSourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E"');

const videoDecoder = new VideoDecoder({
output: frame => {
videoSourceBuffer.appendBuffer(frame);
frame.close();
},
error: e => console.error(e)
});

// Configure decoder based on detected codec
videoDecoder.configure({ codec: 'avc1.42E01E' }); // Example for H.264

// Decode video chunks
videoChunks.forEach(chunk => videoDecoder.decode(chunk));
});

// Play audio
audioElement.play();
}

function parseFLV(buffer) {
// Implement or use a library to parse FLV and extract video/audio chunks
// Return videoChunks, audioChunks, and audioBlob for audio playback
return { videoChunks: [], audioChunks: [], audioBlob: new Blob() };
}

playFLV().catch(console.error);
</script>
</body>
</html>

同时,我们可以将 transmuxing 和解码过程放在 worker 中执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 主线程
const worker = new Worker('flvWorker.js');

worker.onmessage = (event) => {
const { type, data } = event.data;
if (type === 'videoFrame') {
// 将解码后的视频帧传递给视频元素或 MediaSource
// videoElement.appendBuffer(data);
} else if (type === 'audioData') {
// 处理音频数据
// audioElement.appendBuffer(data);
}
};

async function fetchAndPlayFLV(url) {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();

// 将 FLV 数据传递给 Worker
worker.postMessage({ type: 'flvData', data: arrayBuffer }, [arrayBuffer]);
}

fetchAndPlayFLV('path/to/your/video.flv');
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
// web worker
self.onmessage = async (event) => {
const { type, data } = event.data;
if (type === 'flvData') {
const { videoChunks, audioChunks } = parseFLV(data);

const videoDecoder = new VideoDecoder({
output: (frame) => {
self.postMessage({ type: 'videoFrame', data: frame }, [frame]);
},
error: (e) => console.error(e)
});

videoDecoder.configure({ codec: 'avc1.42E01E' });

videoChunks.forEach(chunk => videoDecoder.decode(chunk));

// 类似地处理音频
}
};

function parseFLV(buffer) {
// 实现 FLV 解析逻辑,提取视频和音频数据块
return { videoChunks: [], audioChunks: [] };
}

因为使用了 WebCodecs API,现在我们可以获取到视频的每一帧,可以对视频逐帧进行处理,例如加滤镜:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function applyFilter(imageData) {
const pixels = imageData.data;

// 灰度滤镜示例
for (let i = 0; i < pixels.length; i += 4) {
const red = pixels[i];
const green = pixels[i + 1];
const blue = pixels[i + 2];

// 计算灰度值
const gray = (red + green + blue) / 3;

pixels[i] = gray; // Red
pixels[i + 1] = gray; // Green
pixels[i + 2] = gray; // Blue
// pixels[i + 3] 是 Alpha 通道,保持不变
}

return imageData;
}

浏览器的功能越来越强大了,ChromeOS 越来越有未来了?