├── font_loader.conf ├── .vscode └── settings.json ├── .gitignore ├── LICENSE ├── font_loader ├── uchardet.lua ├── iconv.lua ├── line_iter.lua ├── fc.lua ├── common.lua ├── ass.lua ├── unicode.lua └── main.lua ├── allowCreateSymbolicLink.ps1 └── README.md /font_loader.conf: -------------------------------------------------------------------------------- 1 | fontDir=~/Fonts -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Lua.diagnostics.globals": [ 3 | "mp" 4 | ] 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | 4 | # dependcy 5 | cbor.lua 6 | *.so 7 | *.dylib 8 | *.dll 9 | libs 10 | *.sh 11 | *.tar.gz 12 | inspect.lua 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 wakou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /font_loader/uchardet.lua: -------------------------------------------------------------------------------- 1 | local ffi = require "ffi" 2 | local common = require "common" 3 | local utils = require "mp.utils" 4 | local LIB_UCHARDET_PATH = common.LIB_UCHARDET_PATH 5 | 6 | if utils.file_info(LIB_UCHARDET_PATH) == nil then 7 | return { status = false } 8 | end 9 | 10 | ffi.cdef [[ 11 | typedef void* uchardet_t; 12 | uchardet_t uchardet_new(void); 13 | void uchardet_delete(uchardet_t ud); 14 | int uchardet_handle_data(uchardet_t ud, const char * data, size_t len); 15 | void uchardet_data_end(uchardet_t ud); 16 | void uchardet_reset(uchardet_t ud); 17 | const char * uchardet_get_charset(uchardet_t ud); 18 | ]]; 19 | LIB_UCHARDET = ffi.load(LIB_UCHARDET_PATH) 20 | 21 | local function checkEncoding(filePath) 22 | local assFile = assert(io.open(filePath, 'rb')) 23 | local BUFFER_SIZE = 65535 24 | local ud = LIB_UCHARDET.uchardet_new() 25 | while true do 26 | local data = assFile:read(BUFFER_SIZE) 27 | if data == nil or #data == 0 then break end 28 | local buf = ffi.new("char[?]", string.len(data) + 1, data); 29 | LIB_UCHARDET.uchardet_handle_data(ud, buf, string.len(data)) 30 | end 31 | LIB_UCHARDET.uchardet_data_end(ud); 32 | local charset = LIB_UCHARDET.uchardet_get_charset(ud); 33 | local detRet = ffi.string(charset); 34 | LIB_UCHARDET.uchardet_delete(ud); 35 | return detRet == 'ASCII' and 'UTF-8' or detRet 36 | end 37 | 38 | return { checkEncoding = checkEncoding, status = true } 39 | -------------------------------------------------------------------------------- /font_loader/iconv.lua: -------------------------------------------------------------------------------- 1 | local ffi = require "ffi" 2 | local common = require "common" 3 | local utils = require "mp.utils" 4 | 5 | local LIB_ICONV_PATH = common.LIB_ICONV_PATH 6 | 7 | if utils.file_info(LIB_ICONV_PATH) == nil then 8 | return { status = false } 9 | end 10 | 11 | ffi.cdef [[ 12 | typedef void* iconv_t; 13 | iconv_t libiconv_open(const char* tocode, const char* fromcode); 14 | iconv_t libiconv(iconv_t cd, char** inbuf, size_t *inbytesleft, char** outbuf, size_t *outbytesleft); 15 | iconv_t libiconv_close(iconv_t cd); 16 | ]]; 17 | 18 | LIB_ICONV = ffi.load(LIB_ICONV_PATH) 19 | local iconv_open = LIB_ICONV.libiconv_open 20 | local iconv_close = LIB_ICONV.libiconv_close 21 | local convert = LIB_ICONV.libiconv 22 | 23 | local iconv = { status = true } 24 | 25 | function iconv:iconv(str) 26 | local inLen = string.len(str); 27 | local insize = ffi.new("size_t[1]", inLen); 28 | local instr = ffi.new("char[?]", inLen + 1, str); 29 | local inptr = ffi.new("char*[1]", instr); 30 | local outstr = ffi.new("char[?]", self.bufferSize); 31 | local outptr = ffi.new("char*[1]", outstr); 32 | local outsize = ffi.new("size_t[1]", self.bufferSize); 33 | local err = convert(self.cd, inptr, insize, outptr, outsize); 34 | if err == -1 and (not insize[0] > 0) then 35 | return false, nil, nil 36 | end 37 | local out = ffi.string(outstr, self.bufferSize - outsize[0]); 38 | return true, out, tonumber(insize[0]) 39 | end 40 | 41 | function iconv:new(from, to, bufferSize) 42 | bufferSize = bufferSize == nil and 4096 or bufferSize 43 | local self = { cd = -1, bufferSize = bufferSize } 44 | setmetatable(self, { __index = iconv }) 45 | self.cd = iconv_open(to, from); 46 | -- ffi.gc(self._cd, self.close); 47 | if self.cd == -1 then 48 | return false; 49 | end 50 | return self 51 | end 52 | 53 | function iconv:close() 54 | iconv_close(self.cd); 55 | end 56 | 57 | return iconv 58 | -------------------------------------------------------------------------------- /allowCreateSymbolicLink.ps1: -------------------------------------------------------------------------------- 1 | $Username = "$env:USERDOMAIN`\$env:USERNAME" 2 | $right = "SeCreateSymbolicLinkPrivilege" 3 | 4 | if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { 5 | Start-Process powershell.exe "-File", ('"{0}"' -f $MyInvocation.MyCommand.Path) -Verb RunAs 6 | return 7 | } 8 | 9 | $tempPath = [System.IO.Path]::GetTempPath() 10 | $import = Join-Path -Path $tempPath -ChildPath "import.inf" 11 | if (Test-Path $import) { Remove-Item -Path $import -Force } 12 | $export = Join-Path -Path $tempPath -ChildPath "export.inf" 13 | if (Test-Path $export) { Remove-Item -Path $export -Force } 14 | $secedt = Join-Path -Path $tempPath -ChildPath "secedt.sdb" 15 | if (Test-Path $secedt) { Remove-Item -Path $secedt -Force } 16 | $Error.Clear() 17 | 18 | if ($Username -match "^S-.*-.*-.*$|^S-.*-.*-.*-.*-.*-.*$|^S-.*-.*-.*-.*-.*$|^S-.*-.*-.*-.*$") { 19 | $sid = $Username 20 | } 21 | else { 22 | $sid = ((New-Object System.Security.Principal.NTAccount($Username)).Translate([System.Security.Principal.SecurityIdentifier])).Value 23 | } 24 | secedit /export /cfg $export | Out-Null 25 | $sids = (Select-String $export -Pattern "$right").Line 26 | if ($null -eq $sids) { 27 | $sids = "$right = *$sid" 28 | $sidList = $sids 29 | } 30 | else { 31 | $sidList = "$sids,*$sid" 32 | } 33 | foreach ($line in @("[Unicode]", "Unicode=yes", "[System Access]", "[Event Audit]", "[Registry Values]", "[Version]", "signature=`"`$CHICAGO$`"", "Revision=1", "[Profile Description]", "Description=$ActionType `"$right`" right fouser account: $Username", "[Privilege Rights]", "$sidList")) { 34 | Add-Content $import $line 35 | } 36 | 37 | secedit /import /db $secedt /cfg $import | Out-Null 38 | secedit /configure /db $secedt | Out-Null 39 | gpupdate /force | Out-Null 40 | 41 | Remove-Item -Path $import -Force | Out-Null 42 | Remove-Item -Path $export -Force | Out-Null 43 | Remove-Item -Path $secedt -Force | Out-Null 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpv-font-loader 2 | 3 | 这个脚本会在mpv启动时解析字幕文件并加载相关的字体, 灵感来自[FontLoaderSub](https://github.com/yzwduck/FontLoaderSub). 4 | 5 | 通过`uchardet`与`libiconv`实现了字幕文件编码的检测与转换, 依赖库来自[`Conan`](https://conan.io/) 6 | 7 | ## 安装 8 | 9 | Windows用户须先参考`关于在Windows下使用的特别说明`进行授权操作 10 | 11 | ### MPV 12 | 13 | 1. 找到mpv的设置文件夹, 将`font_loader`目录放置在scripts文件夹下 14 | 2. 将font_loader.conf文件放置在script-opts目录下, 修改font_loader.conf, 将fontDir的值改为用户存放字体文件的目录 15 | 16 | ### IINA 17 | 18 | IINA用户需要进入[设置]-[高级]菜单, 打开`启用高级设置`选项 19 | 20 | * 使用配置目录 21 | 22 | 1. 勾选`使用配置目录`, 并选择配置目录(下面以```~/.config/iina/```为例) 23 | 2. 在目录```~/.config/iina/```下新建文件夹`scripts`和`script-opts` 24 | 3. 将font_loader目录放置在scripts文件夹下, font_loader.conf文件放置在script-opts目录下 25 | 4. 修改font_loader.conf, 将fontDir的值改为用户存放字体文件的目录 26 | 27 | * 不使用配置目录 28 | 29 | 1. 不勾选`使用配置目录`, 以下操作假定脚本路径为`~/mpv_script/font_loader` 30 | 2. 在`额外mpv选项`栏中添加两个选项`scripts`, `script-opts` 31 | 3. 设置scripts选项的值为脚本所在路径, 即`~/mpv_script/font_loader`, 若需添加多个脚本, 则脚本路径以逗号`,`分割, 如`~/mpv_script/font_loader,~/mpv_script/scriptA` 32 | 4. 设置script-opts选项的值为`font_loader-fontDir=字体目录,font_loader-fontIndexFile=customDir/font-index,font_loader-cacheDir=customDir1`, 根据实际情况替换`字体目录`, `customDir`, `customDir1`的值 33 | 34 | ## 使用 35 | 36 | 安装完成后, 打开任意视频即可 37 | 38 | ### 字体库更新 39 | 40 | 若用户有在字体文件夹中添加了新的字体, 需使用`FontLoaderSub`重新生成`fc-subs.db`, 并在字体文件夹下创建`update.txt`文件(文件内容为空即可),脚本检测到此文件会自动更新缓存 41 | 42 | ## 注意事项 43 | 44 | 1. 需要预先使用FontLoaderSub生成fc-subs.db文件 45 | 2. mpv最低版本需要为0.36.0, 该版本开始支持`sub-fonts-dir`属性 46 | 3. 安装完成后初次打开mpv会卡3-5s的时间, 这是脚本在解析fc-subs.db的内容, 之后会生成索引文件, 减少加载时间 47 | 4. 字幕文件中标注的字体较多时, 切换会卡一下(自测只在Windows上出现此问题) 48 | 5. 在Windows系统下, 默认情况下可能无权限创建软链接, 需打开相关权限 49 | 50 | ## 关于在Windows下使用的特别说明 51 | 52 | 本脚本的运行需要进行创建软链接的操作。但在Windows系统上,默认是无权限创建软链接的,因此需先取得权限。 53 | 54 | 以下操作只在Win10上测试过,但应该也适用于Win11 55 | 56 | ### 对于系统为家庭版,教育版等版本的 57 | 58 | 1. 打开设置,进入【更新和安全】-【开发者选项】页面 59 | 2. 打开开发人员模式 60 | 61 | ### 对于系统为专业版及以上版本的 62 | 63 | 专业版除了像家庭版那样开启开发人员模式外,还可选择为当前用户单独打开创建软链接权限。 64 | 65 | #### 通过gui操作 66 | 67 | 1. 打开程序【本地安全策略】 68 | 2. 在程序中进入【本地策略】-【用户权限分配】-【创建符号链接】 69 | 3. 在【创建符号链接 属性】选项卡中,点击【添加用户或组】 70 | 4. 输入你的账号名,点击【检查名称】,然后点击确定 71 | 72 | #### 通过脚本操作 73 | 74 | 1. 下载"allowCreateSymbolicLink.ps1"脚本 75 | 2. PowerShell默认不允许执行脚本, 需以管理员权限打开powershell, 输入"Set-ExecutionPolicy RemoteSigned" 76 | 3. 执行"allowCreateSymbolicLink.ps1"脚本 77 | 78 | ## 实现思路 79 | 80 | 利用了mpv的`sub-fonts-dir`属性. 视频文件载入时, 创建一个临时文件夹, 扫描并解析加载的外置字幕文件所使用的字体, 在文件夹中创建相关字体文件的符号链接, 将该文件夹的路径赋给mpv的`sub-fonts-dir`属性, mpv将会加载该属性所指文件夹中的字体 81 | 82 | ## TODO 83 | 84 | * [x] 支持UTF16编码字幕文件 85 | * [x] 支持GBK编码字幕文件 86 | * [x] 支持其它编码格式 87 | * [ ] 实现`fc-subs.db`文件的变动检查, 并自动更新字体索引文件 88 | * [ ] 实现字体库文件夹的扫描, 去除对`fc-subs.db`文件的依赖 89 | -------------------------------------------------------------------------------- /font_loader/line_iter.lua: -------------------------------------------------------------------------------- 1 | local iconv = require "iconv" 2 | 3 | local line_iter = {} 4 | 5 | function line_iter:new(encoding, assFile) 6 | local iter = { 7 | cd = nil, 8 | encoding = 'UTF-8', 9 | lines = {}, 10 | lines_size = 0, 11 | index = 0, 12 | assFile = nil, 13 | uncompleteLine = '', 14 | leftBytes = '', 15 | fileEnd = false, 16 | lineCount = 0 17 | } 18 | setmetatable(iter, { __index = line_iter }) 19 | 20 | iter.encoding = encoding 21 | iter.assFile = assFile 22 | return iter 23 | end 24 | 25 | function line_iter:next() 26 | local BUFFER_SIZE = 65535 27 | if self.cd == nil then 28 | local cd = iconv:new(self.encoding, 'UTF-8', BUFFER_SIZE) 29 | assert(cd ~= nil) 30 | if cd == false then 31 | return 32 | end 33 | self.cd = cd 34 | end 35 | return function() 36 | if self.index == self.lines_size then 37 | if self.fileEnd then 38 | return nil 39 | end 40 | while true do 41 | local completeStr = self:readData() 42 | if completeStr then 43 | break 44 | end 45 | end 46 | end 47 | self.index = self.index + 1 48 | self.lineCount = self.lineCount + 1 49 | return self.lines[self.index] 50 | end 51 | end 52 | 53 | local function magiclines(str) 54 | local pos = 1; 55 | return function() 56 | if pos == 0 then return nil end 57 | local p1, p2 = string.find(str, "\r?\n", pos) 58 | local line 59 | if p1 then 60 | line = str:sub(pos, p1 - 1) 61 | pos = p2 + 1 62 | else 63 | line = str:sub(pos) 64 | pos = 0 65 | end 66 | return line 67 | end 68 | end 69 | 70 | function line_iter:readData() 71 | local INPUT_SIZE = 55000 72 | local uncompleteLine = self.uncompleteLine 73 | local toUTF8 = self.cd 74 | local leftBytes = self.leftBytes 75 | local str = self.assFile:read(INPUT_SIZE) 76 | if str == nil then 77 | table.insert(self.lines, 1, self.uncompleteLine) 78 | self.lines_size = 1 79 | self.index = 0 80 | self.fileEnd = true 81 | return true 82 | end 83 | if leftBytes ~= '' then 84 | str = leftBytes .. str 85 | end 86 | local ret, retStr, leftBytesSize = toUTF8:iconv(str) 87 | if not ret then 88 | return 89 | end 90 | leftBytes = leftBytesSize == 0 and '' or str:sub(-leftBytesSize, -1) 91 | local index = retStr:match '^.*()\n' 92 | local isUncompleteLine = index == nil and true or false 93 | if isUncompleteLine then 94 | uncompleteLine = uncompleteLine .. retStr 95 | return false 96 | end 97 | local tmp1 = uncompleteLine 98 | uncompleteLine = index == #retStr and '' or retStr:sub(index + 1, -1) 99 | 100 | if tmp1 ~= nil then 101 | retStr = tmp1 .. retStr:sub(1, index) 102 | end 103 | local i = 1 104 | for line in magiclines(retStr) do 105 | table.insert(self.lines, i, line) 106 | i = i + 1 107 | end 108 | self.index = 0 109 | self.lines_size = i - 1 110 | return true 111 | end 112 | 113 | function line_iter:close() 114 | self.assFile:close() 115 | self.cd:close() 116 | end 117 | 118 | return line_iter 119 | -------------------------------------------------------------------------------- /font_loader/fc.lua: -------------------------------------------------------------------------------- 1 | local log = require "mp.msg" 2 | local unicode = require "unicode" 3 | local cbor = require "cbor" 4 | 5 | local function byte2uint32(num1, num2, num3, num4) 6 | return num1 + num2 * 2 ^ 8 + num3 * 2 ^ 16 + num4 * 2 ^ 24 7 | end 8 | 9 | 10 | local function buildIndex(cacheFile) 11 | log.info("open font cache db file: " .. cacheFile) 12 | local cache = assert(io.open(cacheFile, "rb")) 13 | 14 | local headSize = 16 15 | local b1 = cache:read(headSize) 16 | local head = { string.byte(b1, 1, 16) } 17 | local magicNumber = byte2uint32(head[1], head[2], head[3], head[4]) 18 | local fileCount = byte2uint32(head[5], head[6], head[7], head[8]) 19 | local faceCount = byte2uint32(head[9], head[10], head[11], head[12]) 20 | local fileSize = byte2uint32(head[13], head[14], head[15], head[16]) 21 | log.info("db info: [" .. fileCount .. "] font file, [" .. faceCount .. "] font face") 22 | local block = 3000 23 | 24 | local font = { filepath = nil, filename = nil, ver = nil, type = nil, faces = {} } 25 | local buf = {} 26 | local bufIndex = 1; 27 | local fontIndex = {} 28 | while true do 29 | local bytes = cache:read(block) 30 | if not bytes then break end 31 | for i = 1, #bytes do 32 | if i % 2 == 0 then goto continue end 33 | local low, high = string.byte(bytes, i, i + 1) 34 | if low == 10 and high == 0 then 35 | bufIndex = 1 36 | goto continue 37 | end 38 | if low == 0 and high == 0 then 39 | if bufIndex == 1 then 40 | font = { filepath = nil, filename = nil, ver = nil, type = nil, faces = {} } 41 | goto continue 42 | -- break 43 | end 44 | 45 | local codePointList = unicode.fromUTF16(buf, bufIndex - 1) 46 | bufIndex = 1 47 | local utf8str = table.concat(unicode.toUTF8(codePointList)) 48 | 49 | if font.filepath == nil then 50 | local t1 = string.find(utf8str, "\\[^\\]*$") or 1 51 | font.filename = utf8str:sub(t1 + 1, -1) 52 | font.filepath = utf8str:gsub("\\", '/') 53 | fontIndex[font.filename] = font 54 | else 55 | -- \tt type 56 | if utf8str:byte(1, 2) == 9 and utf8str:byte(2, 3) == 116 then 57 | font.type = utf8str:sub(2) 58 | else 59 | -- \tv version 60 | if utf8str:byte(1, 2) == 9 and utf8str:byte(2, 3) == 118 then 61 | font.ver = utf8str:sub(2) 62 | else 63 | -- face 64 | local face = utf8str 65 | fontIndex[face] = font 66 | table.insert(font.faces, face) 67 | end 68 | end 69 | end 70 | goto continue 71 | end 72 | buf[bufIndex] = { low, high } 73 | bufIndex = bufIndex + 1 74 | ::continue:: 75 | end 76 | end 77 | cache:close() 78 | return fontIndex 79 | end 80 | 81 | local function saveIdxToFile(fontIndex, idxFile) 82 | local fontSet = {} 83 | local data = {} 84 | local size = 0 85 | for key, font in pairs(fontIndex) do 86 | if fontSet[key] ~= nil then 87 | goto continue 88 | end 89 | size = size + 1 90 | data[size] = font 91 | for _, face in pairs(font.faces) do 92 | fontSet[face] = true 93 | end 94 | ::continue:: 95 | end 96 | assert(cbor) 97 | local cacheFile = assert(io.open(idxFile, "w")) 98 | cacheFile:write(cbor.encode(data)) 99 | cacheFile:close() 100 | end 101 | 102 | local function loadIdx(idxFile) 103 | local fontIndex = {} 104 | local data = cbor.decode_file(io.open(idxFile, "r")) 105 | local size = 0 106 | for i = 1, #data do 107 | local font = data[i] 108 | local faces = font.faces 109 | for j = 1, #faces do 110 | fontIndex[faces[j]] = font 111 | end 112 | end 113 | return fontIndex 114 | end 115 | 116 | return { 117 | buildIndex = buildIndex, 118 | saveIdxToFile = saveIdxToFile, 119 | loadIdx = loadIdx 120 | } 121 | -------------------------------------------------------------------------------- /font_loader/common.lua: -------------------------------------------------------------------------------- 1 | local utils = require "mp.utils" 2 | local platfrom = mp.get_property("platform") 3 | local win = platfrom == "windows" and true or false; 4 | local script_path = mp.get_script_directory(); 5 | 6 | local busybox = utils.join_path(script_path, "busybox.exe") 7 | local busyboxInfo = utils.file_info(busybox) 8 | local useBusybox = false 9 | 10 | if busyboxInfo ~= nil then 11 | useBusybox = true 12 | end 13 | 14 | local function createLinkArgs(sourceFile, linkFile) 15 | return { "ln", "-s", sourceFile, linkFile } 16 | end 17 | 18 | local function removeLinkArgs(linkFile) 19 | return { "unlink", linkFile } 20 | end 21 | 22 | local function createDirArgs(dirPath) 23 | return { "mkdir", "-p", dirPath } 24 | end 25 | 26 | local function removeEmptyDirArgs(dirPath) 27 | return { "rmdir", dirPath } 28 | end 29 | 30 | local function removeFileArgs(filePath) 31 | return { "rm", filePath } 32 | end 33 | 34 | local function createLinkArgsWin(sourceFile, linkFile) 35 | local sourceFile1 = sourceFile:gsub("/", "\\") 36 | local linkFile1 = linkFile:gsub("/", "\\") 37 | return useBusybox and { busybox, table.unpack(createLinkArgs(sourceFile1, linkFile), 1, 4) } 38 | or { "cmd", "/c", "mklink", linkFile1, sourceFile1 } 39 | end 40 | 41 | 42 | local function removeLinkArgsWin(linkFile) 43 | local linkFile1 = linkFile:gsub("/", "\\") 44 | return useBusybox and { busybox, table.unpack(removeLinkArgs(linkFile1), 1, 2) } 45 | or { "cmd", "/c", "del", linkFile1 } 46 | end 47 | 48 | 49 | local function createDirArgsWin(dirPath) 50 | local dirPath1 = dirPath:gsub("/", "\\") 51 | return useBusybox and { busybox, table.unpack(createDirArgs(dirPath1), 1, 3) } 52 | or { "cmd", "/c", "mkdir", dirPath1 } 53 | end 54 | 55 | 56 | local function removeEmptyDirArgsWin(dirPath) 57 | local dirPath1 = dirPath:gsub("/", "\\") 58 | return useBusybox and { busybox, table.unpack(removeEmptyDirArgs(dirPath1), 1, 2) } 59 | or { "cmd", "/c", "rmdir", dirPath1 } 60 | end 61 | 62 | local function removeFileArgsWin(filePath) 63 | local filePath1 = filePath:gsub("/", "\\") 64 | return useBusybox and { busybox, table.unpack(removeFileArgs(filePath1), 1, 2) } 65 | or { "cmd", "/c", "del", filePath1 } 66 | end 67 | 68 | 69 | local function createSymbolLink(sourceFile, linkFile) 70 | local command = win and createLinkArgsWin or createLinkArgs 71 | local r = mp.command_native({ 72 | name = "subprocess", 73 | playback_only = false, 74 | capture_stdout = true, 75 | args = command(sourceFile, linkFile) 76 | }) 77 | end 78 | 79 | local function removeEmptyDir(dirPath) 80 | local command = win and removeEmptyDirArgsWin or removeEmptyDirArgs 81 | local r = mp.command_native({ 82 | name = "subprocess", 83 | playback_only = false, 84 | capture_stdout = false, 85 | detach = true, 86 | args = command(dirPath) 87 | }) 88 | return r.status == 0 89 | end 90 | 91 | local function removeFile(filePath) 92 | local command = win and removeFileArgsWin or removeFileArgs 93 | local r = mp.command_native({ 94 | name = "subprocess", 95 | playback_only = false, 96 | capture_stdout = false, 97 | detach = true, 98 | args = command(filePath) 99 | }) 100 | return r.status == 0 101 | end 102 | 103 | local function removeLinkFile(linkFile) 104 | local command = win and removeLinkArgsWin or removeLinkArgs 105 | local r = mp.command_native({ 106 | name = "subprocess", 107 | playback_only = false, 108 | capture_stdout = true, 109 | args = command(linkFile) 110 | }) 111 | return r.status == 0 112 | end 113 | 114 | local function createDir(dirPath) 115 | local command = win and createDirArgsWin or createDirArgs 116 | local r = mp.command_native({ 117 | name = "subprocess", 118 | playback_only = false, 119 | capture_stdout = true, 120 | detach = true, 121 | args = command(dirPath) 122 | }) 123 | return r.status == 0 124 | end 125 | 126 | local charset = {} 127 | do -- [0-9a-zA-Z] 128 | for c = 48, 57 do table.insert(charset, string.char(c)) end 129 | for c = 65, 90 do table.insert(charset, string.char(c)) end 130 | end 131 | 132 | local function randomString(length) 133 | if not length or length <= 0 then return '' end 134 | math.randomseed(os.clock() ^ 5) 135 | return randomString(length - 1) .. charset[math.random(1, #charset)] 136 | end 137 | 138 | local WIN_ICONV_NAME = "iconv.dll" 139 | local WIN_UCHARDET_NAME = "uchardet.dll" 140 | local OSX_ICONV_NAME = "libiconv.dylib" 141 | local OSX_UCHARDET_NAME = "libuchardet.dylib" 142 | local LINUX_ICONV_NAME = "libiconv.so" 143 | local LINUX_UCHARDET_NAME = "libuchardet.so" 144 | 145 | local iconvName, uchardetName 146 | if platfrom == "darwin" then 147 | iconvName = OSX_ICONV_NAME 148 | uchardetName = OSX_UCHARDET_NAME 149 | elseif platfrom == "windows" then 150 | iconvName = WIN_ICONV_NAME 151 | uchardetName = WIN_UCHARDET_NAME 152 | else 153 | iconvName = LINUX_ICONV_NAME 154 | uchardetName = LINUX_UCHARDET_NAME 155 | end 156 | 157 | local LIB_ICONV_PATH = utils.join_path(script_path, iconvName) 158 | local LIB_UCHARDET_PATH = utils.join_path(script_path, uchardetName) 159 | 160 | return { 161 | link = createSymbolLink, 162 | unlink = removeLinkFile, 163 | rmdir = removeEmptyDir, 164 | mkdir = createDir, 165 | rm = removeFile, 166 | randomString = randomString, 167 | LIB_ICONV_PATH = LIB_ICONV_PATH, 168 | LIB_UCHARDET_PATH = LIB_UCHARDET_PATH 169 | }; 170 | -------------------------------------------------------------------------------- /font_loader/ass.lua: -------------------------------------------------------------------------------- 1 | local log = require "mp.msg" 2 | local uchardet = require "uchardet" 3 | local line_iter = require "line_iter" 4 | 5 | local function starts_with(str, start) 6 | return str:sub(1, #start) == start 7 | end 8 | 9 | local function trim(s) 10 | return s:match '^%s*(.*%S)' or '' 11 | end 12 | 13 | local function indexOf(array, value, func) 14 | local fFunc = func or function(t) 15 | return t 16 | end 17 | for i, v in ipairs(array) do 18 | if fFunc(v) == value then 19 | return i 20 | end 21 | end 22 | return nil 23 | end 24 | 25 | local function split(str, pat) 26 | local t = {} -- NOTE: use {n = 0} in Lua-5.0 27 | local fpat = "(.-)" .. pat 28 | local last_end = 1 29 | local s, e, cap = str:find(fpat, 1) 30 | while s do 31 | if s ~= 1 or cap ~= "" then 32 | table.insert(t, cap) 33 | end 34 | last_end = e + 1 35 | s, e, cap = str:find(fpat, last_end) 36 | end 37 | if last_end <= #str then 38 | cap = str:sub(last_end) 39 | table.insert(t, cap) 40 | end 41 | return t 42 | end 43 | 44 | local function getFontListFromAss(filePath) 45 | log.info("parse sub file: ", filePath) 46 | local fontList = {} 47 | 48 | local assFile = assert(io.open(filePath, 'rb')) 49 | local iter = assFile.lines 50 | local iterParam = assFile 51 | 52 | if uchardet.status then 53 | local encoding = uchardet.checkEncoding(filePath) 54 | log.info("check sub file [" .. filePath .. "] encoding: " .. encoding) 55 | local des = nil 56 | if encoding ~= 'UTF-8' then 57 | des = line_iter:new(encoding, assFile) 58 | iter = des.next 59 | iterParam = des 60 | end 61 | end 62 | 63 | local section = nil 64 | local styleFontnameIndex = -1 65 | local eventTextCommaIndex = -1 66 | 67 | for line in iter(iterParam) do 68 | if string.lower(string.sub(line, 1, 11)) == "[v4 styles]" 69 | or string.lower(string.sub(line, 1, 12)) == "[v4+ styles]" then 70 | section = "Styles" 71 | goto continue 72 | end 73 | 74 | if string.lower(string.sub(line, 1, 8)) == "[events]" then 75 | section = "Events" 76 | goto continue 77 | end 78 | 79 | if section == "Styles" and starts_with(line, "Format") then 80 | local lineSplitArr = split(line, "[,:]") 81 | styleFontnameIndex = indexOf(lineSplitArr, "Fontname", trim) or -1 82 | if styleFontnameIndex == -1 then 83 | break; 84 | end 85 | goto continue 86 | end 87 | 88 | if section == "Styles" and starts_with(line, "Style") then 89 | local fontname = trim(split(line, "[,:]")[styleFontnameIndex]) 90 | if starts_with(fontname, '@') then 91 | fontname = string.sub(fontname, 2) 92 | end 93 | log.debug("found font: " .. fontname) 94 | table.insert(fontList, fontname) 95 | goto continue 96 | end 97 | 98 | if section == "Events" then 99 | if starts_with(line, "Format") then 100 | local textFormatIndex = string.find(line, 'Text', 8, true); 101 | local _, count = string.gsub(string.sub(line, 1, textFormatIndex), ',', "") 102 | eventTextCommaIndex = count 103 | -- local fontname=trim(split(line,"[,:]")[styleFontnameIndex]) 104 | -- if starts_with(fontname,'@') then 105 | -- fontname=string.sub(fontname,2) 106 | -- end 107 | -- table.insert(fontList,fontname) 108 | goto continue 109 | end 110 | 111 | if starts_with(line, "Dialogue") then 112 | local index, commaCount = 0, 0 113 | for c in line:gmatch "." do 114 | index = index + 1; 115 | if c == "," then 116 | commaCount = commaCount + 1 117 | end 118 | if commaCount == eventTextCommaIndex then break end 119 | -- do something with c 120 | end 121 | 122 | local textStart = index + 1 123 | local text = string.sub(line, textStart) 124 | local styleOverride = false 125 | local prev, prev2char = nil, nil 126 | local findFont = false 127 | local fontnameCharArr = {} 128 | for c in text:gmatch "." do 129 | if styleOverride then 130 | if findFont and (c == "\\" or c == "}") then 131 | findFont = false 132 | local fontname = table.concat(fontnameCharArr) 133 | fontnameCharArr = {} 134 | if starts_with(fontname, '@') then 135 | fontname = string.sub(fontname, 2) 136 | end 137 | table.insert(fontList, fontname) 138 | log.debug("found font: " .. fontname) 139 | end 140 | if findFont and c ~= "\\" then 141 | table.insert(fontnameCharArr, c) 142 | end 143 | if prev2char == "\\" and prev == "f" and c == "n" then 144 | findFont = true 145 | end 146 | end 147 | if c == "{" then 148 | styleOverride = true 149 | end 150 | if c == "}" then 151 | styleOverride = false 152 | end 153 | prev, prev2char = c, prev 154 | end 155 | end 156 | end 157 | 158 | ::continue:: 159 | end 160 | 161 | if des ~= nil then 162 | des:close() 163 | end 164 | return fontList 165 | end 166 | 167 | 168 | 169 | return { 170 | getFontListFromAss = getFontListFromAss 171 | } 172 | -------------------------------------------------------------------------------- /font_loader/unicode.lua: -------------------------------------------------------------------------------- 1 | local bit = require "bit" 2 | 3 | local unicode = {} 4 | local powOfTwo = { [0] = 1, [1] = 2, [2] = 4, [3] = 8, [4] = 16, [5] = 32, [6] = 64, [12] = 4096, [18] = 262144 } 5 | 6 | function unicode.fromUTF16(buf, size) 7 | local codePointList = {} 8 | local highSurrogate = nil 9 | for i = 1, size do 10 | local codeUnit = buf[i] 11 | local low = codeUnit[1] 12 | local high = codeUnit[2] 13 | local byte4Check = bit.rshift(high, 3) == 0x1B 14 | if not byte4Check then 15 | local codePoint = bit.lshift(high, 8) + low 16 | table.insert(codePointList, codePoint) 17 | else 18 | local highProxyCheck = bit.rshift(high, 2) == 0x36 19 | local lowProxyCheck = bit.rshift(high, 2) == 0x37 20 | if highProxyCheck and i == #buf then 21 | return codePointList, codeUnit 22 | end 23 | if highProxyCheck then 24 | highSurrogate = bit.lshift(high - 0xD8, 8) + low 25 | end 26 | if lowProxyCheck then 27 | if highSurrogate == nil then 28 | goto continue 29 | end 30 | local lowSurrogate = bit.lshift(high - 0xDC, 8) + low 31 | local codePoint = bit.lshift(highSurrogate + 1, 16) + lowSurrogate 32 | highSurrogate = nil 33 | table.insert(codePointList, codePoint) 34 | end 35 | end 36 | ::continue:: 37 | end 38 | 39 | return codePointList, nil 40 | end 41 | 42 | function unicode.toUTF16(codePoint) 43 | if codePoint > 65535 then 44 | local a = codePoint - 65536 45 | local first = bit.rshift(a, 10) 46 | local second = bit.band(a, 1024) 47 | local firstLow = bit.band(first, 256) 48 | local firstHigh = bit.rshift(first, 8) + 0xDB 49 | local secondLow = bit.band(second, 256) 50 | local secondHigh = bit.rshift(second, 8) + 0xDC 51 | return firstLow, firstHigh, secondLow, secondHigh 52 | else 53 | local high = bit.rshift(codePoint, 8) 54 | local low = bit.band(codePoint, 256) 55 | return low, high 56 | end 57 | end 58 | 59 | function unicode.fromUTF8(str) 60 | local codePointList = {} 61 | local codePointSize = 0 62 | local buf = nil 63 | local codeUnitShouldSize = 1 64 | local codeUnitSize = 1 65 | for i = 1, #str do 66 | local byte = str:byte(i); 67 | if byte < 128 then 68 | codePointSize = codePointSize + 1 69 | codePointList[codePointSize] = byte 70 | end 71 | if byte >= 128 and byte <= 191 then 72 | if buf ~= nil then 73 | buf = bit.lshift(buf, 6) + byte - 128 74 | codeUnitSize = codeUnitSize + 1 75 | end 76 | end 77 | if byte >= 192 and byte <= 223 then 78 | if buf ~= nil and codeUnitShouldSize == codeUnitSize then 79 | codePointSize = codePointSize + 1 80 | codePointList[codePointSize] = buf 81 | end 82 | buf = byte - 192 83 | codeUnitShouldSize = 2 84 | codeUnitSize = 1 85 | end 86 | if byte >= 224 and byte <= 239 then 87 | if buf ~= nil and codeUnitShouldSize == codeUnitSize then 88 | codePointSize = codePointSize + 1 89 | codePointList[codePointSize] = buf 90 | end 91 | buf = byte - 224 92 | codeUnitShouldSize = 3 93 | codeUnitSize = 1 94 | end 95 | if byte >= 240 and byte <= 247 then 96 | if buf ~= nil and codeUnitShouldSize == codeUnitSize then 97 | codePointSize = codePointSize + 1 98 | codePointList[codePointSize] = buf 99 | end 100 | buf = byte - 240 101 | codeUnitShouldSize = 4 102 | codeUnitSize = 1 103 | end 104 | if byte >= 248 and byte <= 251 then 105 | if buf ~= nil and codeUnitShouldSize == codeUnitSize then 106 | codePointSize = codePointSize + 1 107 | codePointList[codePointSize] = buf 108 | end 109 | buf = byte - 248 110 | codeUnitShouldSize = 5 111 | codeUnitSize = 1 112 | end 113 | if byte >= 252 and byte <= 253 then 114 | if buf ~= nil and codeUnitShouldSize == codeUnitSize then 115 | codePointSize = codePointSize + 1 116 | codePointList[codePointSize] = buf 117 | end 118 | buf = byte - 252 119 | codeUnitShouldSize = 6 120 | codeUnitSize = 1 121 | end 122 | end 123 | 124 | if buf ~= nil and codeUnitShouldSize == codeUnitSize then 125 | codePointSize = codePointSize + 1 126 | codePointList[codePointSize] = buf 127 | end 128 | 129 | return codePointList, codePointSize 130 | end 131 | 132 | function unicode.toUTF8(codePointList) 133 | local byteArray = {} 134 | 135 | while #codePointList ~= 0 do 136 | local codePoint = codePointList[1] 137 | table.remove(codePointList, 1) 138 | local byteCount = 0 139 | if codePoint < 128 then 140 | table.insert(byteArray, string.char(codePoint)) 141 | goto continue 142 | end 143 | if codePoint >= 128 and codePoint < 2048 then 144 | byteCount = 2 145 | end 146 | if codePoint >= 2048 and codePoint < 65536 then 147 | byteCount = 3 148 | end 149 | if codePoint >= 65536 and codePoint < 1114112 then 150 | byteCount = 4 151 | end 152 | local byteU8 = {} 153 | for index = 1, byteCount - 1 do 154 | local codeUnit = 128 + bit.band(bit.rshift(codePoint, (index - 1) * 6), 63) 155 | table.insert(byteU8, codeUnit); 156 | end 157 | local firstUnitNum = bit.rshift(codePoint, (byteCount - 1) * 6) 158 | if not (firstUnitNum < powOfTwo[7 - byteCount]) then 159 | break 160 | end 161 | local firstUnitMask = bit.lshift(powOfTwo[byteCount + 1] - 2,7 - byteCount) 162 | local codeUnit = firstUnitMask + firstUnitNum 163 | table.insert(byteArray, string.char(codeUnit)); 164 | while #byteU8 > 0 do 165 | table.insert(byteArray, string.char(byteU8[#byteU8])) 166 | table.remove(byteU8) 167 | end 168 | ::continue:: 169 | end 170 | return byteArray 171 | end 172 | 173 | return unicode 174 | -------------------------------------------------------------------------------- /font_loader/main.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This script can auto load font which define in external ass file. 3 | 4 | When open a video, this script search for sub tracks with external flag in the track list; 5 | Then script parse sub file (must be a ass type sub file), get font name list. 6 | This script find real font file in font dir accounding font name, 7 | create a symbol link for font file under a temp dir. 8 | 9 | Finally, script use the path of the temp dir as the value of the property `sub-fonts-dir`, 10 | libass would load font in the temp dir. 11 | 12 | This script requires a font dir, this dir stores font files. 13 | The dir path must be passed to the `fontDir` script-opt. 14 | 15 | This script also need other lua module lua-cbor to save index cache to file and load it, because parse fc-subs.db will spent some time, so this script will not use fc-subs.db after first parse. 16 | 17 | The font dir must contain a file called fc-subs.db, the file is generated by a project `FontLoaderSub`. 18 | In fact, the idea of writing this script came from this project 19 | 20 | FontLoaderSub: https://github.com/yzwduck/FontLoaderSub 21 | 22 | This script also need other lua module lua-cbor to save index cache to file and load it, because parse fc-subs.db will spent some time, so this script will not use fc-subs.db after first parse. 23 | 24 | lua-cbor: https://github.com/Zash/lua-cbor 25 | ]] 26 | -- 27 | 28 | local utils = require "mp.utils" 29 | local log = require "mp.msg" 30 | local fc = require "fc" 31 | local ass = require "ass" 32 | local cbor = require "cbor" 33 | local common = require "common" 34 | 35 | local options = { 36 | fontDir = "", 37 | idxDbName = "fc-subs.db", 38 | fontIndexFile = "~~/font-index", 39 | cacheDir = "~~/fontCache/" 40 | } 41 | 42 | require "mp.options".read_options(options, "font_loader") 43 | 44 | local fontDir = mp.command_native({ "expand-path", options.fontDir }) 45 | local baseCacheDir = mp.command_native({ "expand-path", options.cacheDir }) 46 | 47 | log.info("create base cache dir: " .. baseCacheDir) 48 | common.mkdir(baseCacheDir) 49 | 50 | local fontIndexFile = mp.command_native({ "expand-path", options.fontIndexFile }) 51 | local updateFlagFile = mp.command_native({ "expand-path", utils.join_path(fontDir, "update.txt") }); 52 | 53 | local idxFileExist = utils.file_info(fontIndexFile) ~= nil 54 | local updateFlag = utils.file_info(updateFlagFile) ~= nil 55 | local fontIndex 56 | 57 | if cbor == nil or not idxFileExist or updateFlag then 58 | local fontIdxDb = utils.join_path(fontDir, options.idxDbName) 59 | log.info("build index from fc-subs.db") 60 | fontIndex = fc.buildIndex(fontIdxDb) 61 | log.info("build index end") 62 | end 63 | 64 | if cbor ~= nil and idxFileExist and not updateFlag then 65 | log.info("load font index data from index cache file [" .. fontIndexFile .. "] with lua-cbor") 66 | fontIndex = fc.loadIdx(fontIndexFile) 67 | else 68 | log.info("store font index data to cache file") 69 | fc.saveIdxToFile(fontIndex, fontIndexFile) 70 | end 71 | 72 | if updateFlag then 73 | common.rm(updateFlagFile) 74 | log.info("remove update flag file") 75 | end 76 | 77 | local cacheKey = os.date("%Y%m%d%H%M%S_") .. common.randomString(6) 78 | local fontCacheDir = utils.join_path(baseCacheDir, cacheKey) 79 | log.info("create font cache dir, path is: " .. fontCacheDir) 80 | common.mkdir(fontCacheDir) 81 | 82 | local assFileSet = {} 83 | local fontSet = {} 84 | local linkFileList = {} 85 | local linkFileSize = 0 86 | 87 | local function scanNewSubFile(trackList) 88 | local subFileList = {} 89 | local size = 0 90 | for _, track in pairs(trackList) do 91 | if track.type == 'sub' and track.external then 92 | local path = track["external-filename"] 93 | if assFileSet[path] == nil then 94 | size = size + 1 95 | subFileList[size] = path 96 | end 97 | end 98 | end 99 | return subFileList, size 100 | end 101 | 102 | 103 | local function loadFont(_, trackList) 104 | local subFileList, subFileSize = scanNewSubFile(trackList) 105 | local newFont = {} 106 | local newFontSize = 0 107 | 108 | for i = 1, subFileSize do 109 | local file = subFileList[i] 110 | assFileSet[file] = false 111 | local fontList = ass.getFontListFromAss(file) or {} 112 | for _, face in pairs(fontList) do 113 | if fontSet[face] == nil then 114 | local fontFromIdx = fontIndex[face] 115 | if fontFromIdx ~= nil then 116 | newFont[fontFromIdx.filepath] = fontFromIdx.filename 117 | newFontSize = newFontSize + 1 118 | for _, face1 in pairs(fontFromIdx.faces) do 119 | fontSet[face1] = true 120 | end 121 | else 122 | fontSet[face] = false 123 | log.warn("font not find: " .. face) 124 | end 125 | end 126 | end 127 | end 128 | 129 | for filepath, filename in pairs(newFont) do 130 | local linkFileName = filepath == filename and filename or filepath:gsub('/', '-') 131 | local linkFile = utils.join_path(fontCacheDir, linkFileName) 132 | local sourceFile = utils.join_path(fontDir, filepath); 133 | log.debug("create link file: " .. linkFile) 134 | log.info("load font: " .. filepath) 135 | common.link(sourceFile, linkFile) 136 | linkFileSize = linkFileSize + 1 137 | linkFileList[linkFileSize] = linkFile 138 | end 139 | 140 | if newFontSize > 0 then 141 | local sid = mp.get_property_number("sid") or 0 142 | if sid > 0 then 143 | mp.set_property_number("sid", 0) 144 | mp.set_property_number("sid", sid) 145 | end 146 | end 147 | end 148 | 149 | local function removeCache() 150 | for i = 1, linkFileSize do 151 | local linkFile = linkFileList[i] 152 | log.debug("remove font link file: " .. linkFile) 153 | local _, filename = utils.split_path(linkFile) 154 | log.info("unload font: " .. filename) 155 | common.unlink(linkFile) 156 | end 157 | log.debug("remove font cache dir: " .. fontCacheDir) 158 | common.rmdir(fontCacheDir) 159 | end 160 | 161 | local function onFileLoaded(e) 162 | log.debug("event: " .. e.event) 163 | local trackList = mp.get_property_native("track-list") 164 | loadFont(nil, trackList) 165 | end 166 | 167 | local function onTrackListProp(e, trackList) 168 | log.debug("property: " .. e) 169 | loadFont(e, trackList) 170 | end 171 | 172 | mp.set_property("sub-fonts-dir", fontCacheDir) 173 | mp.observe_property("track-list", "native", onTrackListProp) 174 | -- -- mp.register_event("file-loaded", test1) 175 | -- mp.register_event("file-loaded", onFileLoaded) 176 | mp.register_event('shutdown', removeCache) 177 | --------------------------------------------------------------------------------