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:
1 parent
eb3dd548ed
commit
6822de38c7
5 files changed
+267
-23
No files matched your search
+21
-11
@@ -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:"-"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user