我的線上動畫庫 - 讓 SubtitlesOctopus 的 ASS 字體按需載入

前言

前幾天 Leko 輾轉偶然知道 Jellyfin 的線上播放器的字幕渲染居然是用 SubtitlesOctopus (目前瀏覽器唯一可用的 libass 相容 ASS 字幕渲染),真是驚為天人。
Leko 馬上架了一個來用,還因此不辭辛勞手動標記了動畫庫內六千多部動畫的 metadata (花了我整整三天,要得腱鞘炎了XDD)

一開始播放影音的時候會發現,奇怪我的中文字幕怎麼都變豆腐?
原來是因為他的 SubtitlesOctopus 預設沒有載入中文字體。

在官方的 Github Issues 內就有解法了 (載入 NotoCJK),但 Leko 覺得還不夠 -- 既然都可以讓他載入 NotoCJK 了,那我應該也可以讓他跟一般播放器一樣按需載入需要的字體才對。

沒想到居然滿容易的,不到半小時就解決了,在這裡紀錄一下解法。

2021/6/9 更新: 本篇僅適用於 JellyFin 1.6.4 與其之前版本

前置

  • 字體庫 (推薦 超级字体整合包 X)
  • JellyFin (假設使用 Docker 架設)
  • Windows 環境 (僅在設置時需要)
  • FontLoaderSub (用來產生字體索引)
  • node.js (小腳本我用 Node.js 撰寫,因為很簡單,有能力的讀者可以用其他程式語言實現,或是小小修改後在瀏覽器內執行。)

內文

1. 掛載字幕庫

首先我們將字體庫 bind 到 Jellyfin 內的 /jellyfin/jellyfin-web/libraries/fonts/Pack

2. 產生字幕索引

我們需要一個 {字幕名稱: 字幕檔案名稱} 的 JSON,用來配合接下來步驟解析的 ASS 字幕字體。

首先在字幕庫的根目錄放入 FontLoaderSub 並執行,讓它解析完後會產生一個 fc-subs.db,此即字幕索引

觀察可以得知它是一個 UTF-16LE 的文字檔案,格式是

檔案相對路徑
    t:字幕格式
    v:字幕版本
字幕名稱 1
字幕名稱 2
...
空白行

的循環,我們可以寫一個簡單的腳本來把它變成我們需要的 JSON。

// app.js
const fs = require("fs")

const SUBTITLE_DATABASE = "fc-subs.db"
const OUTPUT = "font.json"

const db = fs.readFileSync(SUBTITLE_DATABASE, 'utf16le').split("\n").map(x => x.slice(0, -1))

const result = {};

/*
    0: Font Path
    1: t
    2: v
    -1: Waiting for next block
*/
let stage = 0;
let fontPath = null;
for (const line of db) {
    switch (stage) {
        // 這裡是因為我只想要 `超级字体整合包X` 目錄底下的字體
        // 可以按需修改一下腳本
        case 0:
            if (!line.startsWith("超级字体整合包X")) {
                stage = -1;
                fontPath = null;
            } else {
                stage += 1;
                fontPath = line.slice("超级字体整合包X\\".length)
            }
            continue;
        case 1:
        case 2:
            stage += 1;
            continue;
        case 3:
            if (line.length) {
                result[line] = fontPath;
            } else {
                stage = 0;
            }
            continue;
        case -1:
            if (line.length !== 0) continue;
            stage = 0;
            continue;
    }
}

fs.writeFile(OUTPUT, JSON.stringify(result), 'utf8', function (err) {
    if (err) {
        console.log("An error occured while writing JSON Object to File.");
        return console.log(err);
    }

    console.log("JSON file has been saved.");
});

執行後得到我想要的 font.json,會在第三步驟用到。

3. Plugin.js

Jellyfin 內的 SubtitlesOctopus 主要是在 /jellyfin/jellyfin-web/plugins/htmlVideoPlayer/plugins.js 內 Init 的,我們將他複製出來並 Prettify。

觀察到 renderSsaAss()options 在初始化時有一個 fonts 欄位可以填入 []FontURL,其預設值是

attachments.map(function (i) {
    return apiClient.getUrl(i.DeliveryUrl);
})

簡單的觀察就可以得知我們可以用 getTextTrackUrl(track, item) 來拿到 ASS 字幕的 URL。

因此我們寫幾個簡單的函式來解析 ASS 字幕需要的字體並按需把他加入那個 fonts 欄位。

// Leko Font Loading functions
async function _lekoGetFontNames(url) {
    const rawAssSubtitle = await (await fetch(url)).text();

    let isReachedStyleLine = false
    let formatIndex = null;
    let result = new Set();
    for (const lineRaw of rawAssSubtitle.split("\n")) {
        const line = lineRaw.trim();
        if (!isReachedStyleLine) {
            if (line === "[V4+ Styles]") {
                isReachedStyleLine = true;
            }
            continue;
        } else {
            if (line.startsWith("Format:")) { // Format Line
                formatIndex = line.slice("Format:".length).split(",").findIndex(x => x.includes("Fontname"));
            } else if (line.startsWith("Style:")) { // Style Line
                const fontName = line.slice("Style:".length).split(",")[formatIndex].trim()
                result.add(fontName)
            } else if (line.startsWith("[")) { // Style Block Ended
                break;
            }
        }
    }
    return [...result];
}

var _lekoFontListP = fetch(`${appRouter.baseUrl()}/libraries/fonts/font.json`).then(x => x.json())

async function _lekoFontnameToFilename(fontname) {
    const fontList = await _lekoFontListP;
    return fontList[fontname];
}

function _lekoGetFontUrl(filename, isBasicFont=false){
    if (isBasicFont) {
        return `${appRouter.baseUrl()}/libraries/fonts/${filename}`
    } else {
        return `${appRouter.baseUrl()}/libraries/fonts/Pack/字体/超级字体整合包X/${filename}`
    }
}

其中 _lekoFontListPfont.json 路徑可以自己調整 (我是把它掛到 /jellyfin/jellyfin-web/libraries/fonts/font.json)

_lekoGetFontUrl 內的 else 區塊可以看到 fonts/Pack...,那個就是我們第一步驟掛進去的字體。

然後我們應用這些函式 -- 在 renderSsaAss() 初始化 var options 前加入:

let _lekoFonts = await _lekoGetFontNames(getTextTrackUrl(track, item));
for (let i = 0; i < _lekoFonts.length; i++) {
    _lekoFonts[i] = await _lekoFontnameToFilename(_lekoFonts[i]);
    if (_lekoFonts[i]) _lekoFonts[i] = _lekoGetFontUrl(_lekoFonts[i]);
}
_lekoFonts = _lekoFonts.filter(x => x);

然後 optionsfonts 改為

fonts: attachments.map(function (i) {
    return apiClient.getUrl(i.DeliveryUrl);
}).concat(_lekoFonts)

也可以在此時順便加入一些預設的 CJK 字體。

最後我們將 function renderSsaAss 改為 async,並在有調用它的函數 await 它並適當的把一些函式改為 async 就大功告成。

最後的 plugin.js 供參。

清除瀏覽器快取後應該就能看到 JellyFin 動態按需載入所需字體了,yay!

file

按讚

發佈留言

電子郵件地址不會被公開。必填項已用 * 標註