html 裡用 <video> 播放 MP4 影片檔時,
需要完全的讀取完影片檔才能播放,如果影片本身較長檔案大小較大時,
就會需要等待較長的時間等影片讀取完後才能開始播放,
因為
<video> 需要讀完影片檔中含有影片資訊的 moov (Movie Box)
區塊才會開始播放影片。
我們可以使用 Bento4 工具來查看影片中的檔案結構,
使用 Bento4 的 mp4dump 命令執行以下指令:
D:\Bento4\bin\mp4dump xxx_video.mp4
就可以在命令列視窗中看到影片的結構,
通常的 MP4
除了一開始的含有影片版本協議等資訊的 ftyp (File Type Box) 區塊,
接著的會是一個含有影片資訊的
moov (Movie Box) 區塊和一個含有實際影片數據的 mdat (Media Data Box) 區塊
(其他區塊可先忽略),像這樣:
ftyp + mdat + moov
mdat 和 moov 的擺放位置順序可以換,如果 moov 在 mdat 之後的話,
<video>
會需要在讀取完 moov 後才會開始播放影片。
如果想要提前 <video> 的影片開始播放時間的話,有幾種解決方法:
-
第一個方法是把 moov 放到 mdat 之前,
讓 <video> 先讀取到 moov
的影片資訊,
這樣 <video>
就可以不用等到影片完全載入完就開始播放影片,
檔案結構會像這樣:
ftype
+ moov + mdat
使用
ffmpeg 工具執行以下指令就可以將影片的 moov 搬到 mdat 前面,可參考"ffmpeg 常用命令"這篇文章:
D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -movflags faststart -acodec copy -vcodec copy "moov_moved_video.mp4"
第二個方法是把 MP4 轉成 FMP4 (fragmented mp4), - FMP4 跟一般的 MP4
不同,
除了有 moov 以外,還有 moof (movie fragment) 。
一般的 MP4
檔案結構為一個 moov 和一個 mdat,
而 FMP4
的檔案結構把影片分割成多個片段 (fragment),
每個片段由一組 moof 和一個
mdat 組成,
moof 包含了後面跟著的 mdat 的資訊,
所以 <video>
可以在讀取影片時,先對讀到的片段進行播放,再一邊繼續讀取剩餘的片段,
檔案結構會像這樣:
ftype
+ moof + mdat + moof + mdat + moof + mdat + ........
使用
Bento4 工具的 mp4fragment 命令可以方便地將 MP4 影片轉成 FMP4,
執行以下指令即可:
D:\Bento4\bin\mp4fragment "normal_video.mp4" "fragmented_video.mp4"
也可使用 ffmpeg 工具來進行 FMP4 的轉檔,以下是兩種我在網路上找到的指令,
參數有點不同,不過親自實測都是可以成功轉檔的,
但我使用轉出來的檔案實作邊下載邊播放時有點問題,
有時會出現
Uncaught (in promise) DOMException: Failed to execute 'appendBuffer' on
'SourceBuffer': The HTMLMediaElement.error attribute is not null.
的錯誤訊息,所以我還是會先以用 Bento4 轉檔的檔案來做實作:
D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -strict -2 -movflags frag_keyframe+empty_moov "fragmented_video.mp4"
或
D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -movflags empty_moov+default_base_moof+frag_keyframe "fragmented_video.mp4"
成功把影片轉成 FMP4 後,就可以配合 Javascript 的 Fetch
功能實作邊下載影片檔邊播放的功能,在這裡我要展示一下使用 Javscript 的 Fetch
來邊讀取邊播放來自 servlet (或 jsp 或 nodeJs 等任何你喜歡的 server
程式碼) 的FMP4影片二進位流 (不支援非 FMP4 的影片):
首先是專案檔案結構,其中紅框部份是主要需要的檔案,此專案是一個 Java EE Web
Application 專案,使用 Tomcat server 運行:
專案中放了三個測試的影片檔如下:
- normal_video.mp4:
普通的影片檔,格式如:ftyp + mdat + moov
-
moov_moved_video.mp4:
把 moov 放到 mdat 之前的影片檔,格式如:ftype +
moov + mdat
-
fragmented_video.mp4:
轉檔成 FMP4 的影片檔,格式如:ftype + moof +
mdat + moof + mdat + moof + mdat + ........
首先是把影片檔用二進位輸出的 servlet, GetVideoBinary.java
/testWeb/src/test/GetVideoBinary.java:
package test;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLConnection;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@WebServlet("/GetVideoBinary")
public class GetVideoBinary extends HttpServlet {
private static final long serialVersionUID = 1L;
public GetVideoBinary() {
super();
}
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String videoType = request.getParameter("videoType");
if (videoType == null) {
videoType = "";
}
String videoSourceDir = "/source";
String videoSourceName = "normal_video.mp4";
switch(videoType) {
case "moov_moved_video":
videoSourceName = "moov_moved_video.mp4";
break;
case "fragmented_video":
videoSourceName = "fragmented_video.mp4";
break;
}
String videoSourcePath = videoSourceDir + "/" + videoSourceName;
String contentType = URLConnection.guessContentTypeFromName(videoSourceName);
if (contentType == null) {
contentType = "application/octet-stream";
}
response.setContentType(contentType);
try (BufferedInputStream inputStream = new BufferedInputStream(this.getClass().getResourceAsStream(videoSourcePath));
OutputStream outputStream = response.getOutputStream();
){
byte[] buffer = new byte[1024];
int numRead = 0;
while ((numRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, numRead);
}
}
}
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
service(request, response);
}
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
doGet(request, response);
}
}
在 GetVideoBinary.java 中,接受了一個 "videoType"
參數,來決定要讀取哪一個影片檔做輸出。
再來是主要的測試頁, index.html
/testWeb/WebContent/readableStreamTest/index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Readable Stream Test</title>
<script src="js/fmp4Player.js"></script>
<script>
readAndPlayFMP4("fragmented_video");
</script>
</head>
<body>
<div>
normal_video:
<video muted controls autoplay playsInline style="width: 500px;" id="normal_video" src="/GetVideoBinary?videoType=normal_video"></video>
</div>
<div>
moov_moved_video:
<video muted controls autoplay playsInline style="width: 500px;" id="moov_moved_video" src="/GetVideoBinary?videoType=moov_moved_video"></video>
</div>
<div>
fragmented_video:
<video muted controls autoplay playsInline style="width: 500px;" id="fragmented_video"></video>
</div>
</body>
</html>
index.html 內容非常簡單,頁面中有三個 <video>, 分別用來播放上述的三個
MP4 影片,
normal_video.mp4 和 moov_moved_video.mp4 是直接設定影片 url 在
<video> 的 src 屬性上,
而 fragmented_video 則是呼叫了
"js/fmp4Player.js" 中的 readAndPlayFMP4 函式來載入影片。
最後我們來看一下 "js/fmp4Player.js",
/readableStreamTest/js/fmp4Player.js:
function readAndPlayFMP4(videoPlayerId){
//使用 fetch 讀取影片二進位流
fetch('/GetVideoBinary',
{
method : 'POST',
headers : {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: "videoType=" + videoPlayerId
}).then(function(response) {
var videoPlayer = document.getElementById(videoPlayerId);
var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) {
var mediaSource = new MediaSource();
videoPlayer.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
console.error('Unsupported MIME type or codec: ', mimeCodec);
}
function sourceOpen(_) {
//console.log(this.readyState); // open
var mediaSource = this;
var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
var dataSetReductionStatus = []; // 用來顯示目前 dataset 被拆分的狀態以利觀察 Record current data reduction status for debug
var reader = response.body.getReader();
readNextDataset();
function readNextDataset(){
// 遞迴地讀取影片的二進位流資料 Read video data recursively
return reader.read().then(function(response){
if (response.done) {
//讀取完畢,已沒有可讀取的影片二進位流資料,告訴 mediaSource 所有影片資料已讀取完畢
mediaSource.endOfStream(); //tell media source that it reaches the end of stream, all data from steam has been send.
console.log("All video data handling complete");
return Promise.resolve();
}else{
// response.value is Uint8Array trype
dataSetReductionStatus.unshift(response.value.byteLength);
//把這一波讀到的影片資料 (response.value) 交由 handleData() 處理
return handleData(response.value).then(function(){
// 這波讀取的影片資料均已處理完畢,開始讀取下一波影片資料
console.log("A set of data (total: " + response.value.byteLength + ") handling complete");
readNextDataset();
});
}
});
}
function handleData(data){
return new Promise(function(resolve, reject){
// 在 sourceBuffer.appendBuffer() 之後,必須等到 updateend 事件發生後才能再進行下一次的 sourceBuffer.appendBuffer()
// Need to wait for "updateend" event to do next sourceBuffer.appendBuffer()
sourceBuffer.addEventListener('updateend', sourceBufferUpdateendListener);
function sourceBufferUpdateendListener(_) {
console.log("updateend");
dataSetReductionStatus.shift();
sourceBuffer.removeEventListener('updateend', sourceBufferUpdateendListener);
resolve();
}
try {
// 印出 dataSetReductionStatus 觀察影片資料拆分的狀態
console.log("dataSetReductionStatus: <-- " + dataSetReductionStatus + " <--");
console.log("Try to append: " + data.byteLength);
sourceBuffer.appendBuffer(data); // sourceBuffer.appendBuffer() 有可能得到 "QuotaExceededError" 例外 May catch 'QuotaExceededError' exception
console.log(data.byteLength + " data was appended.");
} catch (e) {
sourceBuffer.removeEventListener('updateend', sourceBufferUpdateendListener);
if (e.name !== 'QuotaExceededError') {
throw e;
}
console.log("QuotaExceededError!");
// 嘗試進行拆分影片資料分別處理,例如此例把影片拆分成總大小(byte) 80% 和 20% 大小的兩個部份 Split data and handle each reduced part of data, for example: reduction: 80%.
var reduction = data.byteLength * 0.8;
if (data.slice(0, reduction).byteLength == 0){
// 如果拆分後 80% 那部份大小為 0 的話,就不拆分,還是處理 100% 的影片資料,但是等一段時間例如 1 秒後再處理
// 以遞迴方式處理資料
console.log("Try to append " + data.byteLength + " after waiting for 1 second.");
setTimeout(function(){
handleData(data).then(resolve);
}, 1000);
}else{
dataSetReductionStatus.shift();
dataSetReductionStatus.unshift(data.slice(reduction).byteLength);
dataSetReductionStatus.unshift(data.slice(0, reduction).byteLength);
// 各別以遞迴方式處理拆分後的資料
// Handle first part of reduced data
console.log("Split data");
handleData(data.slice(0, reduction))
.then(function(){
// Handle second part of reduced data
return handleData(data.slice(reduction));
})
.then(resolve);
}
}
});
}
};
});
}
相關的說明已寫在程式碼的註解當中,這邊再做一些說明:
- 程式使用了 Javascript 的 fetch() 來進行影片資料的讀取,跟 XMLHttpRequest 不同,
XMLHttpRequest 只能在讀取完全部資料後進行資料處理,
但 fetch() 可以使用在 callback 函式中得到的 response 用 response.body.getReader() 來得到
ReadableStream Reader,可以多次地使用 ReadableStream Reader 的 read() 來讀取部份資料,不用一次全部讀完。 - 當進行 sourceBuffer.appendBuffer() 把一段影片資料放到 sourceBuffer 時,
可能會因為 Buffer 已滿而碰到 QuotaExceededError,
這時程式會進行資料拆分,例如以 byte 為單位,先處理前 80% 的資料,再處理後 20% 的資料,
而如果處理各拆分的資料還是碰到 QuotaExceededError 的話,就再繼續遞迴地進行拆分處理,
最後如果要被處理的資料只有 1 byte,則不再進行拆分,當碰到 QuotaExceededError 時,
則等待 1 秒鐘讓 Buffer 有空間時再遞迴地進行處理。 - 程式使用了 Promise 來處理非同步的影片處理,
handleData() 會回應一個 Promise 物件,
當它被 resolve() 時代表一段由 readNextDataset() 讀取到的影片資料被處理完畢,
因為在處理影片時,有可能會碰到 QuotaExceededError 而需要進行影片資料的拆分,
還有在 sourceBuffer.appendBuffer() 時需要等待 updateend 事件發生後才行進行下一次的 sourceBuffer.appendBuffer(),
這些都是非同步的行為,所以使用 Promise 可以較好的處理流程。 - 程式中為了方便地觀察每一次由 readNextDataset() 讀取到的影片資料,
在 sourceBuffer.appendBuffer() 時,資料是被拆分成何種狀態,
所以建立了一個名為 dataSetReductionStatus 的 Arry 物件來觀察,
例如一開始要處理的資料是 1024 byte 時,
dataSetReductionStatus = [1024]
拆分成 819 byte 和 205 byte 時,則是
dataSetReductionStatus = [819, 205]。
最後下面是程式執行的樣子,
normal_video.mp4 會等影片全部讀取完才會開始播放,
moov_moved_video.mp4 會在讀到 moov 時就會開始播放,
fragmented_video.mp4 會在影片檔一邊下載一邊一段段的播放各個 fragment (mdat),
連覽器的 console 則是會列印出一些 debug 訊息。
原始碼分享:
- readableStreamTest.zip (內含範例使用的三個 MP4 影片檔)
資原分享:
-
Bento4-SDK-1-6-0-639.x86_64-microsoft-win32.zip
- ffmpeg.zip
- 在這邊可以免費下載容量較大片長較長的影片做測試用:
https://www.learningcontainer.com/mp4-sample-video-files-download/
我範例使用的影片是:
https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/Sample-Video-File-For-Testing.mp4
參考資料:
-
5分钟入门MP4文件格式
-
MediaSource错误:Failed to execute"endOfStream" on "MediaSource": The
MediaSource‘s readyState is not “open”.
-
Exceeding the buffering quota
-
4.22 mov, mp4, ismv
-
JavaScript Promise 全介紹