diff --git a/src/configs/config.go b/src/configs/config.go index fa35913..8da4679 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -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:"-"), diff --git a/src/configs/config_danmaku_defaults_test.go b/src/configs/config_danmaku_defaults_test.go new file mode 100644 index 0000000..b9cb1a4 --- /dev/null +++ b/src/configs/config_danmaku_defaults_test.go @@ -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 "" + } + return *p +} + +func derefBool(p *bool) any { + if p == nil { + return "" + } + 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) + } +} diff --git a/src/recorders/danmaku/ass.go b/src/recorders/danmaku/ass.go index 21ee501..5a5fee2 100644 --- a/src/recorders/danmaku/ass.go +++ b/src/recorders/danmaku/ass.go @@ -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 } diff --git a/src/recorders/danmaku/ass_lane_regression_test.go b/src/recorders/danmaku/ass_lane_regression_test.go index 24fe586..587ba6a 100644 --- a/src/recorders/danmaku/ass_lane_regression_test.go +++ b/src/recorders/danmaku/ass_lane_regression_test.go @@ -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 +} diff --git a/src/webapp/src/component/file-list/index.tsx b/src/webapp/src/component/file-list/index.tsx index 216d5b4..5c3aa32 100644 --- a/src/webapp/src/component/file-list/index.tsx +++ b/src/webapp/src/component/file-list/index.tsx @@ -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;