package main import ( "math" "os" "path/filepath" "regexp" "strconv" "strings" "testing" ) // avatarXExpr 产出的表达式就长这样:A-(A+T)*(t<±S>)/D var avatarXExprRE = regexp.MustCompile(`^(\d+)-\((\d+)\+(\d+)\)\*\(t([+-]\d+\.\d+)\)/(\d+)$`) // 按块内时间 t 求 avatarXExpr 的值。只认它自己产出的这一种形式; // 表达式一改,这里的正则就会失配,用例会明确报出来 func evalAvatarXExpr(t *testing.T, expr string, tInChunk float64) float64 { t.Helper() m := avatarXExprRE.FindStringSubmatch(expr) if m == nil { t.Fatalf("头像 x 表达式的形式变了,这个用例要跟着改:%s", expr) } playResX, _ := strconv.Atoi(m[1]) base, _ := strconv.Atoi(m[2]) total, _ := strconv.Atoi(m[3]) shift, _ := strconv.ParseFloat(m[4], 64) den, _ := strconv.ParseFloat(m[5], 64) if base != playResX { t.Fatalf("表达式里两个底数不一致:%s", expr) } if den != danmuShowSeconds { t.Fatalf("表达式的分母 %g 和弹幕停留时长 %g 对不上:%s", den, danmuShowSeconds, expr) } // %+.3f 打出来的是带符号的偏移量,所以这里是 t + shift return float64(playResX) - float64(playResX+total)*(tInChunk+shift)/den } // 文字层(libass 的 \move)和头像层(overlay 的 x 表达式)必须是同一条运动轨迹。 // 两边一旦各算各的,被块边界截断的弹幕就会出现「文字已经走到左边、头像还在半路上」—— // 线上看到的就是「头像在中途偏离弹幕轨迹」 func TestAvatarAndTextShareSameMotion(t *testing.T) { const playResX, avPad, total = 1920, 40, 600 for _, t0 := range []float64{0, 0.5, 123.456} { // 块起点取三种典型位置:就是弹幕起播那一刻、在它之前 3 秒(被截断的续画)、 // 以及一个很大的时间轴位置(验证大数下公式也不跑偏) for _, chunkStart := range []float64{t0, t0 - 3, t0 + 2000} { expr := avatarXExpr(playResX, total, chunkStart-t0) for _, tAbs := range []float64{t0, t0 + 0.01, t0 + 1.5, t0 + 9.999, t0 + danmuShowSeconds} { wantText := danmuTextX(playResX, avPad, total, tAbs, t0) gotAv := evalAvatarXExpr(t, expr, tAbs-chunkStart) if math.Abs(gotAv-(wantText-float64(avPad))) > 0.5 { t.Fatalf("t0=%.3f 块起点=%.3f t=%.3f 时文字左边缘 %.2f、头像 %.2f,"+ "两者应当相差一个头像宽度 %d", t0, chunkStart, tAbs, wantText, gotAv, avPad) } } } } // 出发点和终点也要对:起播瞬间文字左边缘刚好在画面右侧外一个「头像+间隔」处 // (对应的头像左边缘正好贴着画面右边界),停留结束时整条刚好完全离开左边界 —— // 起点是 playResX 出头的那点偏移,终点是 -total,行程正好 playResX+total if got := danmuTextX(playResX, avPad, total, 0, 0); got != float64(playResX+avPad) { t.Errorf("起播瞬间文字左边缘应当是 %d,实际 %.2f", playResX+avPad, got) } if got := danmuAvatarX(playResX, avPad, total, 0, 0); got != float64(playResX) { t.Errorf("起播瞬间头像左边缘应当贴着画面右边界 %d,实际 %.2f", playResX, got) } if got := danmuTextX(playResX, avPad, total, danmuShowSeconds, 0); got != float64(avPad-total) { t.Errorf("停留结束时文字左边缘应当是 %d,实际 %.2f", avPad-total, got) } if got := danmuAvatarX(playResX, avPad, total, danmuShowSeconds, 0); got != float64(-total) { t.Errorf("停留结束时头像左边缘(整条的左端)应当是 %d,实际 %.2f", -total, got) } } // 写进 ASS 的 \move 起止点必须按**绝对时间**算,而不是「截断之后的这一小段」: // 拿截断后的时长当整段行程(旧实现:终点写死 -Width)等于把 10 秒的路挤进零点几秒, // 文字会飞过去;分块渲染里 66.4% 的弹幕都被截断过,所以这个错法影响面极大 func TestWriteChunkAssKeepsAbsoluteTrajectory(t *testing.T) { dir := t.TempDir() meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 42} avW, avGap := avatarSize(meta.FontSize), avatarGap(meta.FontSize) total := avW + avGap + 480 // 块从 100 秒开始,弹幕起播在 96 秒 —— 块头把它切掉了,只剩最后 6 秒可见 chunk := danmuChunk{start: 100, end: 130, records: []danmuRecord{ {Time: 96, UID: 7, Name: "甲", Content: "被块头截断的", Lane: 2, Width: 480}, }} path := filepath.Join(dir, "chunk.ass") if err := writeChunkAss(path, meta, chunk, 0, avW, avGap); err != nil { t.Fatal(err) } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } m := regexp.MustCompile(`\\move\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)`).FindStringSubmatch(string(data)) if m == nil { t.Fatalf("ASS 里找不到 \\move:\n%s", data) } nums := make([]int, 4) for i := range nums { nums[i], _ = strconv.Atoi(m[i+1]) } t0 := 96.0 wantX1 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, chunk.start, t0))) wantX2 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, t0+danmuShowSeconds, t0))) if nums[0] != wantX1 || nums[2] != wantX2 { t.Errorf("\\move 起止点应当是 (%d → %d),实际 (%d → %d);"+ "终点按截断后的时长算会把 10 秒的路挤进 %.1f 秒里,文字直接飞过去", wantX1, wantX2, nums[0], nums[2], chunk.end-t0) } // 终点必须是「走完全程、整条离开左边界」的位置,不是文字宽度本身 if nums[2] != avW+avGap-total { t.Errorf("停留结束时的终点应当是 %d(= avPad - total),实际 %d", avW+avGap-total, nums[2]) } if strings.Contains(string(data), "{") && !strings.Contains(string(data), `\an7`) { t.Error("对话行丢了 \\an7 定位锚点") } }