fix(recorders/danmaku,configs,webapp): 修描边虚化失效、刷屏叠印同车道与 128 迁移复活

- writeHeader:OutlineColour 跟随 opacity(BorderStyle=1 下只虚化填充会留实心黑边),outline 对 SC/Guard 全样式生效
- assignLane:并列最早车道从 nextLane 起扫描,占用点取 max 不被短弹幕提前抹掉;时间戳仍恒等于到达时刻,不级联
- configs:新增 danmaku_opacity_migrated 闸门,opacity 128→255 只在首次加载迁移,之后 128 按用户意图保留
- webapp 预览:描边 alpha 与文字 alpha 同口径,top/bottom 区域边界与后端车道一致
This commit is contained in:
Joftal committed 2026-09-29 09:50:20 +08:00
1 parent eb3dd548ed
commit 6822de38c7
5 files changed
+267 -23

No files matched your search

+21 -11
View File
@@ -372,9 +372,9 @@ func (c *Config) StripAllIrrelevantDanmakuFields() {
// migrateDanmakuOpacity 把老配置里遗留的 opacity=128 视为"未设置"并升到 255。
// 旧版本默认写 128,但当时该值只落到 BorderStyle=1 样式的 BackColour 上,渲染结果
// 与 255 完全相同;opacity 现在按"文字不透明度"生效,若不升级,升级后所有存量房间
// 的弹幕会突然变成半透明。代价:在新语义下主动选了 128 的用户,重启后会被升回 255,
// 换个值即可(当前无配置版本号,无法区分"老默认"与"新选择"的 128)。
// 只在加载路径调用,不能让 SetDefaults 做这件事——那会把 API 提交的 128 也悄悄改掉。
// 的弹幕会突然变成半透明。
// 只由 Config.DanmakuOpacityMigrated 闸门调用一次,因此新版面板上重新选定的 128
// 会原样保留;也不能让 SetDefaults 做这件事——那会把 API 提交的 128 也悄悄改掉。
func migrateDanmakuOpacity(d *DanmakuConfig) {
if d != nil && d.Opacity != nil && *d.Opacity == 128 {
d.Opacity = IntPtr(255)
@@ -639,6 +639,10 @@ type Config struct {
DanmakuEnable bool `yaml:"danmaku_enable" json:"danmaku_enable"`
Danmaku DanmakuConfig `yaml:"danmaku" json:"danmaku"`
// 迁移标记:弹幕 opacity 改为"文字不透明度"语义后,只在第一次加载时把遗留的
// 旧默认值 128 升到 255;标记写入后 128 就是用户自己的选择,不再被动过。
DanmakuOpacityMigrated bool `yaml:"danmaku_opacity_migrated,omitempty" json:"danmaku_opacity_migrated,omitempty"`
// 流偏好配置 - 两套系统并存
StreamPreference StreamPreference `yaml:"stream_preference,omitempty" json:"stream_preference,omitempty"` // 新版(渐进迁移中)
@@ -1175,14 +1179,20 @@ func newConfigPostProcess(c *Config) {
// NewConfig() 是把包级 defaultConfig 按值拷贝出来的,弹幕指针字段仍指向默认值;
// 换成独立副本后,任何一份运行时配置改值都不会回写到默认值上。
c.Danmaku.clonePointerFields()
migrateDanmakuOpacity(&c.Danmaku)
for key := range c.PlatformConfigs {
pc := c.PlatformConfigs[key]
migrateDanmakuOpacity(pc.Danmaku)
c.PlatformConfigs[key] = pc
}
for i := range c.LiveRooms {
migrateDanmakuOpacity(c.LiveRooms[i].Danmaku)
// 透明度语义升级只做一次:靠"值等于 128"来识别旧配置是站不住的——新版面板的
// 取值范围本就包含 128,用户明确选了 128 也会被当成老配置升回 255。
// 标记一旦写盘就不再迁移,之后 128 按用户意图原样保留。
if !c.DanmakuOpacityMigrated {
migrateDanmakuOpacity(&c.Danmaku)
for key := range c.PlatformConfigs {
pc := c.PlatformConfigs[key]
migrateDanmakuOpacity(pc.Danmaku)
c.PlatformConfigs[key] = pc
}
for i := range c.LiveRooms {
migrateDanmakuOpacity(c.LiveRooms[i].Danmaku)
}
c.DanmakuOpacityMigrated = true
}
// 规范化手动编辑配置写入的别名域名房间(如 m.douyu.com → www.douyu.com),
// 保证 cookies/平台配置按统一 host 命中。LiveId 是不持久化的瞬时字段(yaml:"-"),
+164
View File
@@ -0,0 +1,164 @@
package configs
import (
"testing"
"gopkg.in/yaml.v3"
)
// 本文件守住弹幕配置两条极易被改回去的不变量:
// 1. yaml.Unmarshal 对非 nil 指针是"顺着指针原地写入",而 NewConfig() 只是把包级
// defaultConfig 按值拷贝出来,弹幕的 *int/*bool 仍指向 defaultDanmakuConfig。
// 不先换成独立副本,任何一份用户配置都会永久改掉包级默认值,且后续新建的每份
// 配置都带着上一份的值。
// 2. opacity 语义升级(旧值渲染无效)带来的 128→255 迁移只能做一次,否则新版面板
// 里用户明确选定的 128 一存盘就丢。
func assertPackageDanmakuDefaultsIntact(t *testing.T, after string) {
t.Helper()
if got := defaultDanmakuConfig.FontSize; got != 36 {
t.Fatalf("%s: 包级默认 font_size = %d, 期望 36", after, got)
}
for _, f := range []struct {
name string
got *int
want int
}{
{"outline", defaultDanmakuConfig.Outline, 1},
{"opacity", defaultDanmakuConfig.Opacity, 255},
} {
if f.got == nil || *f.got != f.want {
t.Fatalf("%s: 包级默认 %s 被改成了 %v, 期望 %d", after, f.name, derefInt(f.got), f.want)
}
}
for _, f := range []struct {
name string
got *bool
}{
{"record_gift", defaultDanmakuConfig.RecordGift},
{"record_douyu_gift", defaultDanmakuConfig.RecordDouyuGift},
{"record_douyin_gift", defaultDanmakuConfig.RecordDouyinGift},
{"record_guard", defaultDanmakuConfig.RecordGuard},
{"record_super_chat", defaultDanmakuConfig.RecordSuperChat},
} {
if f.got == nil || !*f.got {
t.Fatalf("%s: 包级默认 %s 被改成了 %v, 期望 true", after, f.name, derefBool(f.got))
}
}
// 新取出的默认值必须和包级默认一致,且改它不会串到下一份
d := GetDefaultDanmakuConfig()
if d.Opacity == nil || *d.Opacity != 255 || d.Outline == nil || *d.Outline != 1 {
t.Fatalf("%s: GetDefaultDanmakuConfig 返回 %v/%v, 期望 255/1", after, derefInt(d.Opacity), derefInt(d.Outline))
}
*d.Outline = 7
if *defaultDanmakuConfig.Outline != 1 {
t.Fatalf("%s: 修改 GetDefaultDanmakuConfig 的返回值写回了包级默认值", after)
}
}
func derefInt(p *int) any {
if p == nil {
return "<nil>"
}
return *p
}
func derefBool(p *bool) any {
if p == nil {
return "<nil>"
}
return *p
}
func TestCustomDanmakuYamlDoesNotPollutePackageDefaults(t *testing.T) {
yamlText := `danmaku:
font_size: 60
outline: 4
opacity: 10
record_gift: false
record_douyu_gift: false
record_douyin_gift: false
record_guard: false
record_super_chat: false
`
c, err := NewConfigWithBytes([]byte(yamlText))
if err != nil {
t.Fatalf("加载测试配置失败: %v", err)
}
if c.Danmaku.Outline == nil || *c.Danmaku.Outline != 4 {
t.Fatalf("测试配置本身没生效: outline=%v", derefInt(c.Danmaku.Outline))
}
assertPackageDanmakuDefaultsIntact(t, "YAML 加载之后")
// 运行时在配置对象上改值同样不许回写到默认值
live := NewConfig()
*live.Danmaku.Outline = 99
*live.Danmaku.Opacity = 1
*live.Danmaku.RecordGift = false
assertPackageDanmakuDefaultsIntact(t, "运行时改值之后")
}
func TestFailedLoadDoesNotPollutePackageDefaults(t *testing.T) {
if _, err := NewConfigWithBytes([]byte("danmaku:\n opacity: not_a_number\n")); err == nil {
t.Fatal("非法 YAML 期望加载失败")
}
assertPackageDanmakuDefaultsIntact(t, "加载失败之后")
}
func TestMergeResultDoesNotSharePointers(t *testing.T) {
base := GetDefaultDanmakuConfig()
override := GetDefaultDanmakuConfig()
override.Outline = IntPtr(3)
merged := mergeDanmakuConfig(&base, &override)
if merged.Outline == nil {
t.Fatal("合并结果 outline 为空")
}
*merged.Outline = 4
if *override.Outline != 3 {
t.Fatalf("改合并结果把 override 改成了 %d, 期望 3", *override.Outline)
}
if *base.Outline != 1 {
t.Fatalf("改合并结果把 base 改成了 %d, 期望 1", *base.Outline)
}
}
func TestOpacity128MigratesOnlyOnce(t *testing.T) {
c, err := NewConfigWithBytes([]byte("danmaku:\n opacity: 128\n"))
if err != nil {
t.Fatal(err)
}
// 老配置:遗留的默认 128 升到 255,并落下一次性标记
if got := derefInt(c.Danmaku.Opacity); got != 255 {
t.Fatalf("首次加载 opacity = %v, 期望 255", got)
}
if !c.DanmakuOpacityMigrated {
t.Fatal("首次加载没有落下迁移标记")
}
// 用户在新版面板里重新选 128,存盘再加载必须原样保留
c.Danmaku.Opacity = IntPtr(128)
room := LiveRoom{Url: "https://live.bilibili.com/1", OverridableConfig: OverridableConfig{Danmaku: &DanmakuConfig{}}}
room.Danmaku.Opacity = IntPtr(128)
c.LiveRooms = append(c.LiveRooms, room)
pc := PlatformConfig{OverridableConfig: OverridableConfig{Danmaku: &DanmakuConfig{}}}
pc.Danmaku.Opacity = IntPtr(128)
c.PlatformConfigs["bilibili"] = pc
b, err := yaml.Marshal(c)
if err != nil {
t.Fatal(err)
}
c2, err := NewConfigWithBytes(b)
if err != nil {
t.Fatalf("带迁移标记的配置二次加载失败: %v", err)
}
if got := derefInt(c2.Danmaku.Opacity); got != 128 {
t.Fatalf("往返后全局 opacity = %v, 期望保持 128", got)
}
if got := derefInt(c2.LiveRooms[0].Danmaku.Opacity); got != 128 {
t.Fatalf("往返后房间级 opacity = %v, 期望保持 128", got)
}
if got := derefInt(c2.PlatformConfigs["bilibili"].Danmaku.Opacity); got != 128 {
t.Fatalf("往返后平台级 opacity = %v, 期望保持 128", got)
}
}
+21 -8
View File
@@ -180,6 +180,9 @@ func (w *AssWriter) writeHeader() error {
// 再套文字透明度会让白字在彩色底块上发灰,故这两类样式保持不透明。
danmakuColor := fmt.Sprintf("&H%02XFFFFFF", assAlpha)
giftColor := fmt.Sprintf("&H%02X00D4FF", assAlpha)
// 描边必须跟着一起变透明:BorderStyle=1 下 OutlineColour 决定文字外轮廓,
// 只虚化填充会留下一圈实心黑边,opacity=0 也画不出"完全透明"。
outlineColor := fmt.Sprintf("&H%02X000000", assAlpha)
guardBackColor := "&H800080FF"
// SC 各价位背景色 (B站原始配色)
@@ -203,8 +206,8 @@ PlayResY: %d
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Danmaku,%s,%d,%s,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,%d,0,8,0,0,0,1
Style: Gift,%s,%d,%s,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,%d,0,8,0,0,0,1
Style: Danmaku,%s,%d,%s,&H000000FF,%s,&H00000000,0,0,0,0,100,100,0,0,1,%d,0,8,0,0,0,1
Style: Gift,%s,%d,%s,&H000000FF,%s,&H00000000,0,0,0,0,100,100,0,0,1,%d,0,8,0,0,0,1
Style: Guard,%s,%d,&H00FFFFFF,&H000000FF,&H00000000,%s,1,0,0,0,100,100,0,0,3,%d,0,1,0,0,60,1
Style: SC2,%s,%d,&H00FFFFFF,&H000000FF,&H00000000,%s,1,0,0,0,100,100,0,0,3,%d,0,1,0,0,100,1
Style: SC30,%s,%d,&H00FFFFFF,&H000000FF,&H00000000,%s,1,0,0,0,100,100,0,0,3,%d,0,1,0,0,100,1
@@ -219,8 +222,8 @@ Style: SCDefault,%s,%d,&H00FFFFFF,&H000000FF,&H00000000,%s,1,0,0,0,100,100,0,0,3
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
`, w.title, w.resX, w.resY,
w.cfg.FontName, w.cfg.FontSize, danmakuColor, outline,
w.cfg.FontName, w.cfg.FontSize-6, giftColor, outline,
w.cfg.FontName, w.cfg.FontSize, danmakuColor, outlineColor, outline,
w.cfg.FontName, w.cfg.FontSize-6, giftColor, outlineColor, outline,
w.cfg.FontName, w.cfg.FontSize, guardBackColor, outline,
w.cfg.FontName, w.cfg.FontSize, sc2, outline,
w.cfg.FontName, w.cfg.FontSize, sc30, outline,
@@ -420,13 +423,23 @@ func (w *AssWriter) assignLane(startCS int64, textWidth int) int {
// 这里绝不能改成"推迟 startCS 等车道空闲"——每推迟一条就把该车道占用点再往前推一个
// 净空时长,到达速率一旦超过车道吞吐就级联累积且永不收敛(录制 5 分钟能排出几小时的
// 字幕),时间轴与视频彻底对不上。宁可重叠,也不伪造弹幕的到达时间。
earliest := 0
// 并列最小时从 nextLane 起取第一条,避免总是下标 0 胜出把整批弹幕叠在同一车道。
earliest := w.nextLane
minTail := w.laneLast[earliest]
for i := 1; i < w.laneNum; i++ {
if w.laneLast[i] < w.laneLast[earliest] {
earliest = i
idx := (w.nextLane + i) % w.laneNum
if w.laneLast[idx] < minTail {
minTail = w.laneLast[idx]
earliest = idx
}
}
w.laneLast[earliest] = startCS + w.travelCS(safeTextWidth)
// 占用点取两者较大:短弹幕不得把该车道上尚未滚完的长弹幕的占用时刻提前抹掉,
// 否则这条车道会一直是最早空闲的,后续弹幕全部集中叠印上来。
tail := startCS + w.travelCS(safeTextWidth)
if minTail > tail {
tail = minTail
}
w.laneLast[earliest] = tail
w.nextLane = (earliest + 1) % w.laneNum
return earliest
}
@@ -129,3 +129,56 @@ func TestAddDanmakuKeepsArrivalTimeline(t *testing.T) {
}
}
}
// TestAssignLaneKeepsEarlierReservation 验证短弹幕不会把同车道长弹幕已预留的占用点提前。
// 占用点一旦被短弹幕抹掉,这条车道就永远是最早空闲的那条,后续弹幕全部集中叠印在同一行。
func TestAssignLaneKeepsEarlierReservation(t *testing.T) {
w := newLaneTestWriter(t, 10)
defer w.Close()
longWidth := w.estimateTextWidth("用户昵称比较长: 这是一条相当长的弹幕内容用来占满一整屏宽度")
for i := 0; i < w.laneNum; i++ {
w.assignLane(0, longWidth)
}
lane := w.assignLane(100, w.estimateTextWidth("好"))
if got, want := w.laneLast[lane], w.travelCS(longWidth+w.cfg.FontSize); got != want {
t.Fatalf("车道 %d 占用点 = %d 厘秒, 期望保持长弹幕的 %d 厘秒(短弹幕把长弹幕的预留抹掉了)", lane, got, want)
}
}
// TestAssignLaneSpreadsSaturatedBurst 验证车道耗尽后的突发在各行间轮转摊开,
// 而不是集中堆叠到一条车道上。
func TestAssignLaneSpreadsSaturatedBurst(t *testing.T) {
w := newLaneTestWriter(t, 10)
defer w.Close()
longWidth := w.estimateTextWidth("用户昵称比较长: 这是一条相当长的弹幕内容用来占满一整屏宽度")
shortWidth := w.estimateTextWidth("好")
for i := 0; i < w.laneNum; i++ {
w.assignLane(0, longWidth)
}
const burst = 54
counts := make(map[int]int, w.laneNum)
for i := 0; i < burst; i++ {
counts[w.assignLane(int64(100+i), shortWidth)]++
}
if len(counts) != w.laneNum {
t.Fatalf("突发只用了 %d 条车道, 期望全部 %d 条", len(counts), w.laneNum)
}
limit := burst/w.laneNum + 1
if got := maxLaneCount(counts); got > limit {
t.Fatalf("单车道最多 %d 条, 超过均摊上界 %d 条(堆叠在同一行)", got, limit)
}
}
func maxLaneCount(counts map[int]int) int {
max := 0
for _, n := range counts {
if n > max {
max = n
}
}
return max
}
+8 -4
View File
@@ -55,7 +55,7 @@ function parseAssColor(c: string): string {
return assPartsToCss(assColorParts(c));
}
type DanmakuEntry = { start: number; end: number; color: string; text: string; style: string; align: number; bgColor: string; marginV: number };
type DanmakuEntry = { start: number; end: number; color: string; shadow: string; text: string; style: string; align: number; bgColor: string; marginV: number };
/** 解析 ASS 文件,提取所有弹幕条目 */
function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number; resY: number } {
@@ -134,7 +134,11 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number;
if (am) align = parseInt(am[1]);
const text = raw.replace(/\{[^}]*\}/g, '');
const bgColor = styleBackColors[style] || '';
if (end > start && text) items.push({ start, end, color: assPartsToCss(color), text, style, align, bgColor, marginV });
// 预览描边跟随文字透明度:libass 的 OutlineColour 与填充同 alpha,
// 这里若用固定黑色阴影,低透明度弹幕会留一圈预览独有的黑晕。
const shadowAlpha = (0.8 * color.a).toFixed(3);
const shadow = `rgba(0,0,0,${shadowAlpha})`;
if (end > start && text) items.push({ start, end, color: assPartsToCss(color), shadow, text, style, align, bgColor, marginV });
}
return { items, scrollTime: (bannerSpeed * resX) / 1000, resY };
@@ -206,7 +210,7 @@ class DanmakuRenderer {
private isInArea(marginV: number): boolean {
const ratio = marginV / this.resY;
switch (this.settings.area) {
case 'top': return ratio <= 0.5;
case 'top': return ratio < 0.5;
case 'bottom': return ratio >= 0.5;
case 'quarter': return ratio <= 0.25;
case 'three-quarter': return ratio <= 0.75;
@@ -392,7 +396,7 @@ class DanmakuRenderer {
el.style.color = item.color;
el.style.fontSize = fontSize + 'px';
el.style.lineHeight = (fontSize + 4) + 'px';
el.style.textShadow = '1px 1px 2px rgba(0,0,0,0.8),-1px -1px 2px rgba(0,0,0,0.8),1px -1px 2px rgba(0,0,0,0.8),-1px 1px 2px rgba(0,0,0,0.8)';
el.style.textShadow = `1px 1px 2px ${item.shadow},-1px -1px 2px ${item.shadow},1px -1px 2px ${item.shadow},-1px 1px 2px ${item.shadow}`;
// 直接使用 ASS 的 marginV,按比例缩放到 overlay 高度
const scaledTop = (item.marginV / this.resY) * ch;