2019年4月18日 星期四

[GAE] 實作Blob URL與簡單的影音串流

  接續前篇我們將影音檔公開給Client端的<audio>存取使用,但總覺得設成公開好像不太好,應統一都由專案應用來存取,因此在這之後我又研究了要怎樣讓檔案私用Client端又能得到檔案,那我找到的方法是Blob URL。



  在展示做法前…首先我們要有檔案。

  在本地端要做測試,沒有console介面讓我們能只靠點選就上傳檔案,因此我們先要建個表單讓我們能上傳東西進Storage中。(事實上本地端沒有提供Google Cloud Storage的服務,但有blobstore,使用cloud storage API存進default bucket的東西都會在 blobstore 可以看到)

  那我們就拿之前的[GAE] 使用Google Cloud Storage存取圖片這篇寫的Code來用吧,有做點修改,改的更簡單點。(另外這篇的程式碼我都省略router.py的部分,這部分請參考之前寫的)

1. /templates/upload.html:上傳用的表單頁面
<html>
<head></head>
    <meta charset="UTF-8">
    <title>Upload</title>
<body>
    <form action="" enctype="multipart/form-data" method="post">
        <div class="form-group">
            <label for="uploaded_file">File:</label>
            <input type="file" id="uploaded_file" name="uploaded_file">
        </div>
        <div class="form-group">
            <button type="submit">Send</button>
        </div>
    </form>
</body>
</html>

2. main.py:變得就是上傳檔案到Cloud Storage,沒再多做存BlobKey與URL到Datastore的動作。
class UploadHandler(webapp2.RequestHandler):
    def get(self):
        template = jinja_enviroment.get_template('upload.html')
        self.response.write(template.render())

    def post(self):
        upload_file = self.request.POST['uploaded_file']

        file_title = upload_file.filename
        file_content = upload_file.file.read()
        file_type = upload_file.type

        bucket_name = os.environ.get('BUCKET_NAME',
                               app_identity.get_default_gcs_bucket_name())

        cloud_storage_path = '/gs/'+bucket_name+'/%s' %(file_title)

        with gcs.open(cloud_storage_path[3:], 'w', content_type=file_type) as f: 
            f.write(file_content)

        self.response.write('Upload Success')


  如此我們就有能上傳檔案的管道了,至於要看上傳了哪些東西,只要到 localhost:8000/datastore 的 blobstore viewer就可以看到了,但因為看不到檔案名字不太好用就是了,要自己記上傳了什麼。(因為本地端測試沒有提供Google Cloud Storage,若是使用blobstore的API就可以設定filename了,但官方都推薦使用Google Cloud Storage不要用Blobstore,所以就將就一下吧)



Blob URL (或稱 Oobject-URL):
  Blob URL是在瀏覽器內部由Blob或File生成的URL,相當於一個reference指向該Blob或File物件,Blob URL的生命週期只存在該頁面中,因此複製了該Blob URL連結貼到瀏覽器上,是不能得到物件資料的。

  我們先來試試 image 檔案吧,在我們的Blob viewer有一個04.jpg的圖片。

bloburl.html:重點就是 URL.createObjectURL( )
<!DOCTYPE html>
<html>
<head>
    <title>Blob Demo</title>
    <meta charset="utf-8">
</head>
<body>
<script>
    try {
        var xhr = new XMLHttpRequest();  // 實例 XMLHttpRequest 物件
        xhr.open("GET", "/getphoto?filename=04.jpg"); // Get加上query filename
        xhr.responseType = 'blob';
        xhr.onload = function () {  // 非同步取得回應
            let blob = xhr.response;
            let img = document.createElement("img");
            var blobUrl = window.URL.createObjectURL(blob);
            img.src = blobUrl; 
            document.body.appendChild(img);
        };
        xhr.send(null);
    } catch (e) {
        console.error(e);
    }
</script>
</body>
</html>

main.py:HTTP Content-Type = 'aplication/octect-stream'
class BlobHandler(webapp2.RequestHandler):
    def get(self):
        template = jinja_enviroment.get_template('bloburl.html')
        self.response.write(template.render())

class SendPhotoHandler(webapp2.RequestHandler):
    def get(self):
        bucket_name = os.environ.get('BUCKET_NAME',
                               app_identity.get_default_gcs_bucket_name())
        filename = self.request.get('filename')  # 取得Get送來的參數
        filepath = "/" + bucket_name + "/" + filename
        gcs_file = gcs.open(filepath)
        contents = gcs_file.read()
        gcs_file.close()
        self.response.headers['Content-Type'] = 'application/octect-stream' # 送回binary object
        self.response.write(contenst)

  結果: 能看到 <img> 裡的 url長像這樣 blob:https://... 這樣,影音檔也可以用差不多的方式完成,如此我們就不需要將Cloud Storage的檔案設做公開,而是統一由Server端讀取再傳送資料到Client端使用。



  但這麼做有很大的缺點,那就是必須等資料傳送完全才可以使用,圖片檔容量小可能還沒什麼感覺,但若是影音檔的話要等全部資料傳送完成才能看,這等待時間應該沒人受得了。

  因此影音檔我們要再結合影音分段串流的技術讓我們能邊接收資料邊播放!

  下面我是實做音檔mp3串流。
  要將影音檔分段傳送有兩種方式,一種是上傳Cloud Storage時就把原檔分割成好幾個檔案,第二種則是讀檔時寫定要讀取的範圍 (第幾byte到第幾byte),那這邊我採用的是第二種。

main.py:這邊我是使用 blobstore_handlers.BlobstoreDownloadHandler 來做,它的 self.send_blob() 可以從BlobKey取得檔案,並設定讀取範圍。這裡我偷懶直接從blobstore viewer那看要傳送的檔案的BlobKey值。(官方文件:https://cloud.google.com/appengine/docs/standard/python/tools/webapp/blobstorehandlers)
class BlobHandler(webapp2.RequestHandler):
    def get(self):
        template = jinja_enviroment.get_template('down.html')
        self.response.write(template.render())
        
class BlobDownHandler(blobstore_handlers.BlobstoreDownloadHandler):
    def get(self):
        start = int(self.request.get('start'))
        end = int(self.request.get('end'))
        blob_key = blobstore.BlobKey('encoded_gs_file:YXBwX2RlZmF1bHRfYnVja2V0LzAxLm1wMw==')
        if not blobstore.get(blob_key):
            self.error(404)
        else:
            self.send_blob(blob_key, start=start, end=end)

down.html:使用XMLHttpRequest()依序送出多次GET request,每次的傳送都附帶startByte與endByte讓Server端知道要傳送檔案的哪部分。另外我這邊我也偷懶直接讓Client端知道請求的檔案有多大,還有播放所需的時間,一般這是要另外請求獲得的。
<html>
<head>
</head>
<body>
    <audio id="myaudio" controls></audio>
    <script>
        var filesize = 1968694  //bytes
        var NUM_CHUNKS = 100;
        var audio = document.getElementById("myaudio");
        var mediaSource = new MediaSource();
        audio.src = URL.createObjectURL(mediaSource);     
        var sourceBuffer = null;
        function callback(e) {
            var sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
            mediaSource.duration = 49; // seconds
            var chunkSize = Math.ceil(filesize / NUM_CHUNKS);
            var xhr = new XMLHttpRequest();
            (function loop(i, length) {
                if (i>= length) {
                    return;
                }
                var startByte = chunkSize * i;
                var endByte = startByte+chunkSize-1;
                var params = "start="+startByte+"&end="+endByte;

                xhr.open('GET', "/blobdown?"+params, true);
                xhr.responseType = 'arraybuffer';
                xhr.onload = function(e) {
                    if(xhr.readyState === XMLHttpRequest.DONE) {
                        sourceBuffer.appendBuffer(new Uint8Array(xhr.response));
                        loop(i + 1, length);
                    }
                }
                xhr.send();
            })(0, NUM_CHUNKS);
        }
        mediaSource.addEventListener('sourceopen', callback, false);
    </script>
</body>
</html>

* 寫作這樣 (function loop(i, length){ ... loop(i+1, length); ...})(0, NUM_CHUNKS) 是確保 XMLHttpRequest 依序送出

* mediaSource.duration = 49; 是告知音檔總長49秒,因為我們是片段送檔案,所以播放器不會知道音檔總大小,因此要另外告訴播放器音檔長度,若沒設定 mediaSource.duration 的話進度條就不會運作。 (這個bug我找超久的...)

* 上面的Code最讓人疑惑的東西大概是 MediaSource和sourceBuffer 了吧,建議能看看這篇文章: How video streaming works on the web: An introduction,我覺得講解的很清楚。

  如此我們就能不用等待完全下載完就可以播放音檔了!

  上面只是展示影音檔串流的能做到邊下載邊播放,所以程式碼有許多投機偷懶的地方,真要實做串流還有許多問題要思考。



參考資料:
  1. https://blog.darkthread.net/blog/html5-object-url/
  2. https://www.hongkiat.com/blog/mediasource-api-stream-truncated-audio/
  3. https://wwwhtml5rockscom.readthedocs.io/en/latest/content/tutorials/streaming/multimedia/en/

沒有留言:

張貼留言