最近一段时间在鼓捣一个视频播放项目,被 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; } parse ( ) { this .parseHeader (); while (this .position < this .data .byteLength ) { this .parseTag (); this .position += 4 ; } } } 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 ) { let position = 0 ; while (position < data.length ) { if (data[position] === 0x00 && data[position + 1 ] === 0x00 && data[position + 2 ] === 0x00 && data[position + 3 ] === 0x01 ) { position += 4 ; } else { position++; } } }
使用 MSE 进行播放。
使用 Media Source Extensions 创建一个 MediaSource 对象。
将解码后的音视频数据添加到 SourceBuffer 中。
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 ) { const flvStreamUrl = 'your-flv-stream-url.flv' ; fetch (flvStreamUrl) .then (response => response.arrayBuffer ()) .then (data => { 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 ; const signature = flvData.slice (position, position + 3 ); position += 3 ; if (signature !== 'FLV' ) throw new Error ('Not a valid FLV file' ); position += 2 ; 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 ; position += 4 ; const data = flvData.slice (position, position + dataSize); position += dataSize; tags.push ({ tagType, timestamp, data }); 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 ( ) { return new Uint8Array ([...]); } function createMoovBox (tags ) { return new Uint8Array ([...]); } function createMdatBox (tags ) { return new Uint8Array ([...]); } function concatenateBuffers (...buffers ) { 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 承担了一层数据转换的工作。具体来说,目前的播放方式存在以下问题:
我们需要使用 JavaScript 将 FLV 包装转换为 MP4 包装,尽管 JavaScript 已经很快了,但这种 buffer 操作还是会有负担,同时要注意这一操作是在主线程执行的,可能会影响用户的浏览(flv.js 提供了 enableWorker 选项,将 transmuxing 放在 worker 中执行,可以解决这一问题)。
其实我们只是转换了一层包装,并没有解码能力,实际的解码播放操作还是浏览器的 <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' ) { } else if (type === 'audioData' ) { } }; async function fetchAndPlayFLV (url ) { const response = await fetch (url); const arrayBuffer = await response.arrayBuffer (); 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 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 ) { 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; pixels[i + 1 ] = gray; pixels[i + 2 ] = gray; } return imageData; }
浏览器的功能越来越强大了,ChromeOS 越来越有未来了?