Web 端 APNG 播放實現原理

雲音樂大前端團隊發表於2020-08-06

題圖來源:https://commons.wikimedia.org

本文作者:楊彩芳

寫在前面

在雲音樂的直播開發中會常遇到動畫播放的需求,每個需求的應用場景不同,體積較小的動畫大都採用 APNG 格式。

如果動畫僅單獨展示可以使用 <img> 直接展示 APNG 動畫,但是會存在相容性 Bug,例如:部分瀏覽器不支援 APNG 播放,Android 部分機型重複播放失效。

如果需要將 APNG 動畫 和 其他 DOM 元素 結合 CSS3 Animation 展示動畫,APNG 就需要預載入和受控,預載入能夠防止 APNG 解析花費時間,從而出現二者不同步的問題,受控能夠有利於使用者在 APNG 解析成功或播放結束等時間節點進行一些操作。

這些問題 apng-canvas 都可以幫我們解決。apng-canvas 採用 canvas 繪製 APNG 動畫,可以相容更多的瀏覽器,抹平不同瀏覽器的差異,且便於控制 APNG 播放。下面將具體介紹 APNG 、apng-canvas 庫實現原理以及在 apng-canvas 基礎上增加的 WebGL 渲染實現方式。

APNG 簡介

APNG(Animated Portable Network Graphics,Animated PNG)是基於 PNG 格式擴充套件的一種點陣圖動畫格式,增加了對動畫影像的支援,同時加入了 24 位真彩色影像和 8 位 Alpha 透明度的支援,動畫擁有更好的質量。APNG 對傳統 PNG 保留向下相容,當解碼器不支援 APNG 播放時會展示預設影像。

除 APNG 外,常見的動畫格式還有 GIF 和 WebP。從瀏覽器相容性、尺寸大小和圖片質量三方面比較,結果如下所示(其中尺寸大小以一張圖為例,其他純色或多彩圖片尺寸大小比較可檢視 GIF vs APNG vs WebP ,大部分情況下 APNG 體積更小)。綜合比較 APNG 更優,這也是我們選用 APNG 的原因。

APNG 結構

APNG 是基於 PNG 格式擴充套件的,我們首先了解下 PNG 的組成結構。

PNG 結構組成

PNG 主要包括 PNG SignatureIHDRIDATIEND 和 一些輔助塊。其中,PNG Signature 是檔案標示,用於校驗檔案格式是否為 PNG ;IHDR 是檔案頭資料塊,包含影像基本資訊,例如影像的寬高等資訊;IDAT 是影像資料塊,儲存具體的影像資料,一個 PNG 檔案可能有一個或多個 IDAT 塊;IEND 是結束資料塊,標示影像結束;輔助塊位於 IHDR 之後 IEND 之前,PNG 規範未對其施加排序限制。

PNG Signature 塊的大小為 8 位元組,內容如下:

0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a

其他每個塊的組成結構基本如下所示:

4 個位元組標識資料的長度,4 個位元組標識塊型別,length 個位元組為資料(如果資料的長度 length 為 0,則無該部分),最後4個位元組是CRC校驗。

APNG 結構組成

APNG 在 PNG 的基礎上增加了 acTLfcTLfdAT 3 種塊,其組成結構如下圖所示:

  • acTL:動畫控制塊,包含了圖片的幀數和迴圈次數( 0 表示無限迴圈)
  • fcTL:幀控制塊,屬於 PNG 規範中的輔助塊,包含了當前幀的序列號、影像的寬高及水平垂直偏移量,幀播放時長和繪製方式(dispose_op 和 blend_op)等,每一幀只有一個 fcTL
  • fdAT:幀資料塊,包含了幀的序列號和影像資料,僅比 IDAT 多了幀的序列號,每一幀可以有一個或多個 fcTL 塊。fdAT 的序列號與 fcTL 共享,用於檢測 APNG 的序列錯誤,可選擇性的糾正

IDAT 塊是 APNG 向下相容展示時的預設圖片。如果 IDAT 之前有 fcTL, 那麼 IDAT 的資料則當做第一幀圖片(如上圖結構),如果 IDAT 之前沒有 fcTL,則第一幀圖片是第一個 fdAT,如下圖所示:

APNG 動畫播放主要是通過 fcTL 來控制渲染每一幀的影像,即通過 dispose_op 和 blend_op 控制繪製方式。

  • dispose_op 指定了下一幀繪製之前對緩衝區的操作

    • 0:不清空畫布,直接把新的影像資料渲染到畫布指定的區域
    • 1:在渲染下一幀前將當前幀的區域內的畫布清空為預設背景色
    • 2:在渲染下一幀前將畫布的當前幀區域內恢復為上一幀繪製後的結果
  • blend_op 指定了繪製當前幀之前對緩衝區的操作

    • 0:表示清除當前區域再繪製
    • 1:表示不清除直接繪製當前區域,影像疊加

apng-canvas 實現原理

瞭解 APNG 的組成結構之後,我們就可以分析 apng-canvas 的實現原理啦,主要分為兩部分:解碼和繪製。

APNG 解碼

APNG 解碼的流程如下圖所示:

首先將 APNG 以arraybuffer 的格式下載資源,通過檢視操作二進位制資料;然後依次校驗檔案格式是否為 PNG 及 APNG;接著依次拆分 APNG 每一塊處理並儲存;最後將拆分獲得的 PNG 標示塊、頭塊、其他輔助塊、一幀的幀影像資料塊和結束塊重新組成 PNG 圖片並通過載入影像資源。在這個過程中需要瀏覽器支援 Typed ArraysBlob URLs

APNG 的檔案資源是通過 XMLHttpRequest 下載,實現簡單,這裡不做贅述。

校驗 PNG 格式

校驗 PNG 格式就是校驗 PNG Signature 塊,將檔案資源從第 1 個位元組開始依次比對前 8 個位元組的內容,關鍵實現如下:

const bufferBytes = new Uint8Array(buffer); // buffer為下載的arraybuffer資源
const PNG_SIGNATURE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
for (let i = 0; i < PNG_SIGNATURE_BYTES.length; i++) {
    if (PNG_SIGNATURE_BYTES[i] !== bufferBytes[i]) {
        reject('Not a PNG file (invalid file signature)');
        return;
    }
}

校驗 APNG 格式

校驗 APNG 格式就是判斷檔案是否存在型別為 acTL 的塊。因此需要依序讀取檔案中的每一塊,獲取塊型別等資料。塊的讀取是根據上文所述的 PNG 塊的基本組成結構進行處理,流程實現如下圖所示:

off 初始值為 8,即 PNG Signature 的位元組大小,然後依序讀取每一塊。首先讀取 4 個位元組獲取資料塊長度 length,繼續讀取 4 個位元組獲取資料塊型別,然後執行回撥函式處理本塊的資料,根據回撥函式返回值 res、塊型別和 off 值判斷是否需要繼續讀取下一塊(res 值表示是否要繼續讀取下一塊資料,預設為 undefined 繼續讀取)。如果繼續則 off 值累加 4 + 4 + length + 4,偏移到下一塊的開始迴圈執行,否則直接結束。關鍵程式碼如下:

const parseChunks = (bytes, callback) => {
    let off = 8;
    let res, length, type;
    do {
        length = readDWord(bytes, off);
        type = readString(bytes, off + 4, 4);
        res = callback(type, bytes, off, length);
        off += 12 + length;
    } while (res !== false && type !== 'IEND' && off < bytes.length);
};

呼叫 parseChunks 從頭開始查詢,一旦存在 type === 'acTL' 的塊就返回 false 停止讀取,關鍵實現如下:

let isAnimated = false;
parseChunks(bufferBytes, (type) => {
    if (type === 'acTL') {
        isAnimated = true;
        return false;
    }
    return true;
});
if (!isAnimated) {
    reject('Not an animated PNG');
    return;
}

按照型別處理每一塊

APNG 結構中的核心型別塊的詳細結構如下圖所示:

呼叫 parseChunks 依次讀取每一塊,根據每種型別塊中包含的資料及其對應的偏移和位元組大小分別進行處理儲存。其中在處理 fcTLfdAT 塊時跳過了幀序列號 (sequence_number)的讀取,似乎沒有考慮序列號出錯的問題。關鍵實現如下:

let preDataParts = [], // 儲存 其他輔助塊
    postDataParts = [], // 儲存 IEND塊
    headerDataBytes = null; // 儲存 IHDR塊

const anim = anim = new Animation();
let frame = null; // 儲存 每一幀

parseChunks(bufferBytes, (type, bytes, off, length) => {
    let delayN,
        delayD;
    switch (type) {
        case 'IHDR':
            headerDataBytes = bytes.subarray(off + 8, off + 8 + length);
            anim.width = readDWord(bytes, off + 8);
            anim.height = readDWord(bytes, off + 12);
            break;
        case 'acTL':
            anim.numPlays = readDWord(bytes, off + 8 + 4); // 迴圈次數
            break;
        case 'fcTL':
            if (frame) anim.frames.push(frame); // 上一幀資料
            frame = {}; // 新的一幀
            frame.width = readDWord(bytes, off + 8 + 4);
            frame.height = readDWord(bytes, off + 8 + 8);
            frame.left = readDWord(bytes, off + 8 + 12);
            frame.top = readDWord(bytes, off + 8 + 16);
            delayN = readWord(bytes, off + 8 + 20);
            delayD = readWord(bytes, off + 8 + 22);
            if (delayD === 0) delayD = 100;
            frame.delay = 1000 * delayN / delayD;
            anim.playTime += frame.delay; // 累加播放總時長
            frame.disposeOp = readByte(bytes, off + 8 + 24);
            frame.blendOp = readByte(bytes, off + 8 + 25);
            frame.dataParts = [];
            break;
        case 'fdAT':
            if (frame) frame.dataParts.push(bytes.subarray(off + 8 + 4, off + 8 + length));
            break;
        case 'IDAT':
            if (frame) frame.dataParts.push(bytes.subarray(off + 8, off + 8 + length));
            break;
        case 'IEND':
            postDataParts.push(subBuffer(bytes, off, 12 + length));
            break;
        default:
            preDataParts.push(subBuffer(bytes, off, 12 + length));
    }
});
if (frame) anim.frames.push(frame); // 依次儲存每一幀幀資料

組裝 PNG

拆分完資料塊之後就可以組裝 PNG 了,遍歷 anim.frames 將 PNG 的通用資料塊 PNG_SIGNATURE_BYTES、 headerDataBytes、preDataParts、一幀的幀資料 dataParts 和postDataParts 按序組成一份 PNG 影像資源(bb),通過 createObjectURL 建立圖片的 URL 儲存到frame中,用於後續繪製。

const url = URL.createObjectURL(new Blob(bb, { type: 'image/png' }));
frame.img = document.createElement('img');
frame.img.src = url;
frame.img.onload = function () {
    URL.revokeObjectURL(this.src);
    createdImages++;
    if (createdImages === anim.frames.length) { //全部解碼完成
        resolve(anim);
    }
};

到這裡我們已經完成了解碼工作,呼叫 APNG.parseUrl 就可以實現動畫資源預載入功能:頁面初始化之後首次呼叫載入資源,渲染時再次呼叫直接返回解析結果進行繪製操作。

const url2promise = {};
APNG.parseURL = function (url) {
    if (!(url in url2promise)) {
        url2promise[url] = loadUrl(url).then(parseBuffer);
    }
    return url2promise[url];
};

APNG 繪製

APNG 解碼完成後就可以根據動畫控制塊和幀控制塊繪製播放啦。具體是使用 requestAnimationFrame在 canvas 畫布上依次繪製每一幀圖片實現播放。apng-canvas 採用 Canvas 2D 渲染。

const tick = function (now) {
    while (played && nextRenderTime <= now) renderFrame(now);
    if (played) requestAnimationFrame(tick);
};

Canvas 2D 繪製主要是使用 Canvas 2D 的 API drawImageclearRectgetImageDataputImageData 實現。

const renderFrame = function (now) {
    // fNum 記錄迴圈播放時的總幀數
    const f = fNum++ % ani.frames.length;
    const frame = ani.frames[f];
    // 動畫播放結束
    if (!(ani.numPlays === 0 || fNum / ani.frames.length <= ani.numPlays)) {
        played = false;
        finished = true;
        if (ani.onFinish) ani.onFinish(); // 這行是作者加的便於在動畫播放結束後執行一些操作
        return;
    }

    if (f === 0) {
        // 繪製第一幀前將動畫整體區域畫布清空
        ctx.clearRect(0, 0, ani.width, ani.height);  
        prevF = null; // 上一幀
        if (frame.disposeOp === 2) frame.disposeOp = 1;
    }

    if (prevF && prevF.disposeOp === 1) { // 清空上一幀區域的底圖
        ctx.clearRect(prevF.left, prevF.top, prevF.width, prevF.height);
    } else if (prevF && prevF.disposeOp === 2) { // 恢復為上一幀繪製之前的底圖
        ctx.putImageData(prevF.iData, prevF.left, prevF.top);
    } // 0 則直接繪製

    const {
        left, top, width, height,
        img, disposeOp, blendOp
    } = frame;
    prevF = frame;
    prevF.iData = null;
    if (disposeOp === 2) { // 儲存當前的繪製底圖,用於下一幀繪製前恢復該資料
        prevF.iData = ctx.getImageData(left, top, width, height);
    }
    if (blendOp === 0) { // 清空當前幀區域的底圖
        ctx.clearRect(left, top, width, height);
    }

    ctx.drawImage(img, left, top); // 繪製當前幀圖片

    // 下一幀的繪製時間
    if (nextRenderTime === 0) nextRenderTime = now;
    nextRenderTime += frame.delay; // delay為幀間隔時間
};

WebGL 繪製

渲染方式除 Canvas 2D 外還可以使用 WebGL。WebGL 渲染效能優於 Canvas 2D,但是 WebGL 沒有可以直接繪製影像的 API,繪製實現程式碼較為複雜,本文就不展示繪製影像的具體程式碼,類似 drawImage API 的 WebGL 實現可參考 WebGL-drawimage二維矩陣等。下面將介紹作者選用的繪製實現方案的關鍵點。

由於 WebGL 沒有 getImageDataputImageData 等 API 可以獲取或複製當前畫布的影像資料,所以在 WebGL 初始化時就初始化多個紋理,使用變數 glRenderInfo 記錄歷史渲染的紋理資料。

// 紋理數量
const textureLens = ani.frames.filter(item => item.disposeOp === 0).length;

// 歷史渲染的紋理資料
const glRenderInfo = {
    index: 0,
    frames: {},
};

渲染每一幀時根據 glRenderInfo.frames 使用多個紋理依次渲染,同時更新 glRenderInfo 資料。

const renderFrame = function (now) {
    ...
    let prevClearInfo;
    if (f === 0) {
        glRenderInfo.index = 0;
        glRenderInfo.frames = {};
        prevF = null;
        prevClearInfo = null;
        if (frame.disposeOp === 2) frame.disposeOp = 1;
    }
    if (prevF && prevF.disposeOp === 1) { //  需要清空上一幀區域底圖
        const prevPrevClear = glRenderInfo.infos[glRenderInfo.index].prevF;
        prevClearInfo = [
            ...(prevPrevClear || []),
            prevF,
        ];
    }
    if (prevF && prevF.disposeOp === 0) { // 遞增紋理下標序號,否則直接替換上一幀圖片
        glRenderInfo.index += 1;
    }
    // disposeOp === 2 直接替換上一幀圖片
    glRenderInfo.frames[glRenderInfo.index] = { // 更新glRenderInfo
        frame,
        prevF: prevClearInfo, // 用於清除上一幀區域底圖
    };
    prevF = frame;
    prevClearInfo = null;
    // 繪製圖片,底圖清除在 glDrawImage 介面內部實現
    Object.entries(glRenderInfo.frames).forEach(([key, val]) => {
        glDrawImage(gl, val.frame, key, val.prevF);
    });
    ...
}

小結

本文介紹了 APNG 的結構組成、圖片解碼、使用 Canvas 2D / WebGL 渲染實現。希望閱讀本文後,能夠對您有所幫助,歡迎探討。

參考

本文釋出自 網易雲音樂大前端團隊,文章未經授權禁止任何形式的轉載。我們常年招收前端、iOS、Android,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章