优化在线字幕

This commit is contained in:
truewhile
2026-09-17 23:53:22 +08:00
parent ae8db26c1e
commit f4abdf78e2
3 changed files with 374 additions and 9 deletions
+167
View File
@@ -0,0 +1,167 @@
package service
import (
"bytes"
"encoding/binary"
"math"
"regexp"
"strings"
"unicode"
"unicode/utf16"
"unicode/utf8"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
)
// 外挂字幕文件的编码归一化。
//
// 字幕文件由字幕组/工具生成,编码并不统一:除 UTF-8 外,中文圈常见 GBK/GB18030,
// 港台常见 Big5,部分 Windows 工具(Aegisub 早期版本、Subtitle Edit 的某些导出)
// 会写成带 BOM 的 UTF-16LE。浏览器 <track> 与 libass(JASSUB)都只按 UTF-8 解析,
// 后端如果原样下发字节,非 UTF-8 的字幕会整篇变成替换字符——表现就是「字幕轨已经
// 选中,但画面上一个字都不显示」。所以外挂字幕在服务端就要统一解码成 UTF-8。
//
// 注意:只用于下发给浏览器/播放器的文本路径。Emby 兼容层的 /subtitles 原始字节
// 接口(ServeRaw)必须保持字节一致,不能经过这里。
// 各编码的 BOM 前缀。
var (
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
utf16BEBOM = []byte{0xFE, 0xFF}
utf16LEBOM = []byte{0xFF, 0xFE}
)
// subtitleLegacyEncodings 是无 BOM 时的候选编码,按优先级排列。
// GB18030 是 GBK/GB2312 的超集,中文外挂字幕绝大多数是它,因此排在最前——
// 多个候选都能解出合法字幕时,靠前的优先。
var subtitleLegacyEncodings = []struct {
name string
enc encoding.Encoding
}{
{"gb18030", simplifiedchinese.GB18030},
{"big5", traditionalchinese.Big5},
{"shift_jis", japanese.ShiftJIS},
{"euc-kr", korean.EUCKR},
}
// subtitleTimecodePattern 匹配 SRT(00:00:01,000 --> ...)与 WebVTT
// (00:00:01.000 --> ...)的时间行。
var subtitleTimecodePattern = regexp.MustCompile(`\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}\s*-->`)
// decodeSubtitleText 把字幕文件字节归一化成 UTF-8 文本。识别不出编码时原样返回,
// 保证不会比「直接透传」更糟。
func decodeSubtitleText(raw []byte) string {
switch {
case bytes.HasPrefix(raw, utf8BOM):
return string(raw[len(utf8BOM):])
case bytes.HasPrefix(raw, utf16LEBOM):
return decodeUTF16(raw[len(utf16LEBOM):], false)
case bytes.HasPrefix(raw, utf16BEBOM):
return decodeUTF16(raw[len(utf16BEBOM):], true)
}
// 无 BOM 且是合法 UTF-8:这是绝大多数情况,直接返回。
if utf8.Valid(raw) {
return string(raw)
}
best := ""
bestScore := math.MinInt
consider := func(text string) {
if !utf8.ValidString(text) || !looksLikeSubtitle(text) {
return
}
score := subtitlePlausibility(text)
if score > bestScore {
best, bestScore = text, score
}
}
// 无 BOM 的 UTF-16 少见(多来自手工编辑),但一旦是它,所有单字节编码都会
// 解出带大量 NUL 的垃圾,因此先用「格式特征」筛掉这些候选。
consider(decodeUTF16(raw, false))
consider(decodeUTF16(raw, true))
for _, candidate := range subtitleLegacyEncodings {
text, err := candidate.enc.NewDecoder().Bytes(raw)
if err != nil {
continue
}
consider(string(text))
}
if best != "" {
return best
}
return string(raw)
}
// decodeUTF16 按给定字节序把 UTF-16 字节解码为字符串(含代理对)。
func decodeUTF16(raw []byte, bigEndian bool) string {
units := make([]uint16, 0, len(raw)/2)
for i := 0; i+1 < len(raw); i += 2 {
if bigEndian {
units = append(units, binary.BigEndian.Uint16(raw[i:i+2]))
} else {
units = append(units, binary.LittleEndian.Uint16(raw[i:i+2]))
}
}
return string(utf16.Decode(units))
}
// looksLikeSubtitle 判断解码结果是否真的像一份字幕。解码器「没报错」并不代表
// 解对了——很多编码能把任意字节解成合法文本,只有格式特征才能确认。
func looksLikeSubtitle(text string) bool {
head := text
if len(head) > 4096 {
head = head[:4096]
}
if strings.Contains(head, "[Script Info]") || strings.Contains(head, "Dialogue:") {
return true // ASS/SSA
}
if strings.HasPrefix(strings.TrimSpace(head), "WEBVTT") {
return true
}
return subtitleTimecodePattern.MatchString(head) // SRT / WebVTT 时间行
}
// subtitlePlausibility 给解码结果打分,返回 -100..100 的可信度:正常文本字符加分,
// 替换字符(解码失败)、控制字符、未分配码位扣分。
//
// 刻意用比例而不是字符数量:单字节回退会把一个双字节字符拆成两个字符,字符多的
// 候选看起来「更正常」,实际是解错了——GBK 中文字幕被当成 Shift_JIS 解,就会得到
// 一串半角片假名,数量比正确结果还多。半角片假名单独扣分正是为了压住这种情况。
func subtitlePlausibility(text string) int {
total := 0
weight := 0
for _, r := range text {
total++
switch {
case r == utf8.RuneError:
weight -= 8
case r == '\n' || r == '\r' || r == '\t':
// 正常换行不计分也不扣分。
case r < 0x20 || r == 0x7F:
weight -= 8
case unicode.Is(unicode.Co, r) || unicode.Is(unicode.Cn, r):
weight -= 4
case r >= 0xFF61 && r <= 0xFF9F:
// 半角片假名:GBK 汉字被按 Shift_JIS 解出来的典型产物。
weight -= 2
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul),
unicode.Is(unicode.Latin, r), unicode.IsDigit(r), unicode.IsPunct(r):
weight++
case unicode.IsSpace(r):
// 空格(含全角空格)不参与打分。
default:
weight--
}
}
if total == 0 {
return 0
}
return weight * 100 / total
}