From 47eeb8f334e262f40135e298d61011ecd507f01c Mon Sep 17 00:00:00 2001 From: qydysky Date: Mon, 24 Feb 2025 02:30:06 +0800 Subject: [PATCH] =?utf8?q?Add=20=E4=BF=AE=E6=94=B9Ass=E7=94=9F=E6=88=90=20?= =?utf8?q?(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * Add 修改Ass生成 * Fix ci --- CV/Var.go | 4 + F/api.go | 2 + README.md | 33 ++++- Reply/F.go | 4 - Reply/F/ass/ass.go | 246 ++++++++++++++++++++++++------------ Reply/F/ass/ass_test.go | 19 +++ Reply/F/ass/testdata/0.csv | 19 +++ Reply/F/comp.go | 4 +- Reply/Reply.go | 13 -- Reply/stream.go | 8 +- bili_danmu.go | 2 + demo/config/config_K_v.json | 16 ++- 12 files changed, 259 insertions(+), 111 deletions(-) create mode 100644 Reply/F/ass/ass_test.go create mode 100644 Reply/F/ass/testdata/0.csv diff --git a/CV/Var.go b/CV/Var.go index ea42f7d..dea2984 100644 --- a/CV/Var.go +++ b/CV/Var.go @@ -50,6 +50,7 @@ type Common struct { PID int `json:"-"` //进程id Version string `json:"-"` //版本 Uid int `json:"-"` //client uid + Login bool `json:"login"` //登陆 Live []*LiveQn `json:"live"` //直播流链接 Live_qn int `json:"liveQn"` //当前直播流质量 Live_want_qn int `json:"-"` //期望直播流质量 @@ -104,6 +105,7 @@ func (t *Common) MarshalJSON() ([]byte, error) { ParentAreaID int AreaID int Locked bool + Login bool Note string LiveStartTime string Liveing bool @@ -120,6 +122,7 @@ func (t *Common) MarshalJSON() ([]byte, error) { ParentAreaID: t.ParentAreaID, AreaID: t.AreaID, Locked: t.Locked, + Login: t.Login, Note: t.Note, LiveStartTime: t.Live_Start_Time.Format(time.DateTime), Liveing: t.Liveing, @@ -196,6 +199,7 @@ func (t *Common) IsOn(key string) bool { func (t *Common) Copy() *Common { var c = Common{ + Login: t.Login, InIdle: t.InIdle, PID: t.PID, Version: t.Version, diff --git a/F/api.go b/F/api.go index d9b5826..fc9acd2 100644 --- a/F/api.go +++ b/F/api.go @@ -947,6 +947,7 @@ func (t *GetFunc) Get_cookie() (missKey []string) { } } + t.Login = true apilog.L(`I: `, `已登录`) return } @@ -954,6 +955,7 @@ func (t *GetFunc) Get_cookie() (missKey []string) { } } + t.Login = false t.Uid = 0 apilog.L(`I: `, `未登录`) diff --git a/README.md b/README.md index f665ec5..539efb6 100644 --- a/README.md +++ b/README.md @@ -679,10 +679,37 @@ I: 2022/09/15 02:23:23 Msg [qydysky丶 : 赞] 当所选类型在当前直播中不可用时,会按以下顺序[`fmp4`,`flv`]尝试。 -ass编码GB18030支持中文 +~~ass编码GB18030支持中文~~ -- `GB18030`(默认) -- `utf-8` +~~- `GB18030`(默认)~~ +~~- `utf-8`~~ + +Ass默认`utf-8`编码,在录播结束时生成,可以配合`指定房间录制回调`生成硬编码弹幕的视频,配置项如下(>v0.15.9) + +```json +{ + "指定房间录制回调":[ + { + "roomid-help":"0替换为指定的房间号", + "roomid":0, + "durationS":10, + "after":["ffmpeg","-i","0.{type}","-y","-vf","ass=0.ass","1.{type}"] + } + ], + "Ass": { + "fontname-help":"指定字体", + "fontname":"", + "fontsize-help":"字体大小int", + "fontsize":40, + "showSec-help":"弹幕显示时长int,秒", + "showSec":10, + "area-help":"弹幕显示区域,float64,0-1(全屏)", + "area":1.0, + "alpha-help":"弹幕透明度,float64,0(不透明)-1(透明)", + "alpha":1.0 + } +} +``` 弹幕回放(仅直播流Web服务) diff --git a/Reply/F.go b/Reply/F.go index 77559e3..ae43844 100644 --- a/Reply/F.go +++ b/Reply/F.go @@ -1334,10 +1334,6 @@ func init() { if !file.New(v+"0.csv", 0, true).CheckRoot(s).IsExist() { w.WriteHeader(http.StatusNotFound) return - } else if !file.New(v+"0.xml", 0, true).CheckRoot(s).IsExist() { - if _, e := danmuXml.DanmuXml.Run(context.Background(), &v); e != nil { - flog.L(`E: `, e) - } } if s, closeF := PlayRecDanmu(v + "0.csv"); s == nil { diff --git a/Reply/F/ass/ass.go b/Reply/F/ass/ass.go index c048fcf..74deb7a 100644 --- a/Reply/F/ass/ass.go +++ b/Reply/F/ass/ass.go @@ -1,117 +1,201 @@ package ass import ( - "context" + "bytes" + "encoding/json" "fmt" + "iter" "math" "strconv" - "time" + "unicode/utf8" - p "github.com/qydysky/part" + "github.com/dustin/go-humanize" comp "github.com/qydysky/part/component2" - pctx "github.com/qydysky/part/ctx" file "github.com/qydysky/part/file" - encoder "golang.org/x/text/encoding" - "golang.org/x/text/encoding/simplifiedchinese" +) + +var ( + playResX = 1280 //字幕宽度 + playResY = 720 //字幕高度 ) type i interface { - Assf(s string) error - Ass_f(ctx context.Context, enc, savePath string, st time.Time) + ToAss(savePath string) + Init(cfg any) } func init() { - if e := comp.Register[i]("ass", &Ass{header: Ass_header}); e != nil { + if e := comp.Register[i]("ass", &Ass{ + fontsize: 40, + showSec: 10, + area: 1.0, + alpha: 0, + header: `[Script Info] +Title: Default Ass file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +PlayResX: ` + strconv.Itoa(playResX) + ` +PlayResY: ` + strconv.Itoa(playResY) + ` + +[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: Default,,40,&H40FFFFFF,&H000017FF,&H80000000,&H40000000,0,0,0,0,100,100,0,0,1,1,0,7,20,20,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +`, + }); e != nil { panic(e) } } -var ( - Ass_height = 720 //字幕高度 - Ass_width = 1280 //字幕宽度 - Ass_font = 50 //字幕字体大小 - Ass_T = 7 //单条字幕显示时间 - Ass_loc = 7 //字幕位置 小键盘对应的位置 - accept = map[string]encoder.Encoding{ - ``: simplifiedchinese.GB18030, - `GB18030`: simplifiedchinese.GB18030, - `utf-8`: nil, - } - Ass_header = `[Script Info] - Title: Default Ass file - ScriptType: v4.00+ - WrapStyle: 0 - ScaledBorderAndShadow: yes - PlayResX: ` + strconv.Itoa(Ass_height) + ` - PlayResY: ` + strconv.Itoa(Ass_width) + ` - - [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: Default,,` + strconv.Itoa(Ass_font) + `,&H40FFFFFF,&H000017FF,&H80000000,&H40000000,0,0,0,0,100,100,0,0,1,4,4,` + strconv.Itoa(Ass_loc) + `,20,20,50,1 - - [Events] - Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text - ` -) - // Ass 弹幕转字幕 type Ass struct { - savePath string //弹幕ass文件名 - startT time.Time //开始记录的基准时间 - header string //ass开头 - wrap encoder.Encoding //编码 + showSec, fontsize int + area float64 + alpha int + header string //ass开头 } -func (t *Ass) Assf(s string) error { - if t.savePath == "" || s == "" { - return nil +func (t *Ass) Init(cfg any) { + if c, ok := cfg.(map[string]any); !ok { + return + } else { + fontname := c[`fontname`].(string) + if tmp, ok := c[`fontsize`].(float64); ok && tmp > 0 { + t.fontsize = int(tmp) + } + if tmp, ok := c[`showSec`].(float64); ok && tmp > 0 { + t.showSec = int(tmp) + } + if tmp, ok := c[`area`].(float64); ok && tmp >= 0 { + t.area = tmp + } + if tmp, ok := c[`alpha`].(float64); ok && tmp >= 0 { + t.alpha = int(tmp * 255) + } + t.header = `[Script Info] +Title: Default Ass file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +PlayResX: ` + strconv.Itoa(playResX) + ` +PlayResY: ` + strconv.Itoa(playResY) + ` + +[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: Default,` + fontname + `,` + strconv.Itoa(t.fontsize) + `,&H40FFFFFF,&H000017FF,&H80000000,&H40000000,0,0,0,0,100,100,0,0,1,1,0,7,20,20,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +` } +} - st := time.Since(t.startT) + time.Duration(p.Rand().MixRandom(0, 2000))*time.Millisecond - et := st + time.Duration(Ass_T)*time.Second - - var b string - // b += "Comment: " + strconv.Itoa(loc) + " "+ Dtos(showedt) + "\n" - b += `Dialogue: 0,` - b += dtos(st) + `,` + dtos(et) - b += `,Default,,0,0,0,,{\fad(200,500)\blur3}` + s + "\n" - - f := file.New(t.savePath+"0.ass", -1, true) - f.Config.Coder = t.wrap - if _, e := f.Write([]byte(b), true); e != nil { - return e +func (t *Ass) ToAss(savePath string) { + f := file.New(savePath+"0.ass", 0, false) + defer f.Close() + if f.IsExist() { + _ = f.Delete() } - return nil -} -func (t *Ass) Ass_f(ctx context.Context, enc, savePath string, st time.Time) { - if v1, ok := accept[enc]; ok { - t.wrap = v1 + lsSize := int(float64(playResY) * t.area / float64(t.fontsize)) + var lsd = make([]float64, lsSize) + var lso = make([]float64, lsSize) + + var write bool + for line := range loadCsv(savePath) { + if !write { + _, _ = f.Write([]byte(t.header), true) + write = true + } + + danmul := utf8.RuneCountInString(line.Text) + danmuSec := (float64(t.showSec*t.fontsize*danmul) / float64(t.fontsize*danmul+playResX)) + + c := -1 + + for i := 0; i < lsSize; i++ { + if lsd[i] > line.Time+float64(t.showSec)-danmuSec { + continue + } + if lso[i] > line.Time { + continue + } + { + lsd[i] = line.Time + float64(t.showSec) //line.Time + (float64(showSec*fontsize*danmul) / float64(fontsize*danmul+playResX)) + lso[i] = line.Time + +danmuSec + c = i + break + } + } + + if c == -1 { + continue + } + + _, _ = f.Write([]byte( + `Dialogue: 0,`+ + stos(line.Time)+`,`+stos(line.Time+float64(t.showSec))+ + `,Default,,0,0,0,,{`+ + `\c&H`+line.Style.Color[5:7]+line.Style.Color[3:5]+line.Style.Color[1:3]+`&`+ + `\alpha&H`+fmt.Sprintf("%02x", t.alpha)+`&`+ + `\move(`+strconv.Itoa(playResX)+`,`+strconv.Itoa(c*t.fontsize)+`,-`+strconv.Itoa(t.fontsize*danmul)+`,`+strconv.Itoa(c*t.fontsize)+`)`+ + `}`+line.Text+"\n"), true) } +} - t.savePath = savePath - f := &file.File{ - Config: file.Config{ - FilePath: t.savePath + "0.ass", - AutoClose: true, - Coder: t.wrap, - }, +func loadCsv(savePath string) iter.Seq[Data] { + return func(yield func(Data) bool) { + csvf := file.New(savePath+"0.csv", 0, false) + defer csvf.Close() + + if !csvf.IsExist() { + return + } + + var data = Data{} + for i := 0; true; i += 1 { + if line, e := csvf.ReadUntil([]byte{'\n'}, humanize.KByte, humanize.MByte); len(line) != 0 { + lined := bytes.SplitN(line, []byte{','}, 3) + if len(lined) == 3 { + if t, e := strconv.ParseFloat(string(lined[0]), 64); e == nil { + if e := json.Unmarshal(lined[2], &data); e == nil { + data.Time = t + if data.Style.Color == "" { + data.Style.Color = "#FFFFFF" + } + if !yield(data) { + return + } + } + } + } + } else if e != nil { + break + } + } } - _, _ = f.Write([]byte(t.header), true) - t.startT = st +} - ctx, done := pctx.WaitCtx(ctx) - defer done() - <-ctx.Done() +type DataStyle struct { + Color string `json:"color"` + Border bool `json:"border"` + Mode int `json:"mode"` +} - t.savePath = "" +type Data struct { + Text string `json:"text"` + Style DataStyle `json:"style"` + Time float64 `json:"time"` } -// 时间转化为0:00:00.00规格字符串 -func dtos(t time.Duration) string { - M := int(math.Floor(t.Minutes())) % 60 - S := int(math.Floor(t.Seconds())) % 60 - Ns := t.Nanoseconds() / int64(time.Millisecond) % 1000 / 10 +func stos(sec float64) string { + H := int(math.Floor(sec)) / 3600 + M := int(math.Floor(sec)) % 3600 / 60 + S := int(math.Floor(sec)) % 60 + Ns := int(sec*1000) % 1000 / 10 - return fmt.Sprintf("%d:%02d:%02d.%02d", int(math.Floor(t.Hours())), M, S, Ns) + return fmt.Sprintf("%d:%02d:%02d.%02d", H, M, S, Ns) } diff --git a/Reply/F/ass/ass_test.go b/Reply/F/ass/ass_test.go new file mode 100644 index 0000000..d8bc7ed --- /dev/null +++ b/Reply/F/ass/ass_test.go @@ -0,0 +1,19 @@ +package ass + +import ( + "testing" + + comp "github.com/qydysky/part/component2" +) + +func TestMain(t *testing.T) { + var ass = comp.Get[interface { + ToAss(savePath string) + Init(cfg any) + }](`ass`) + ass.ToAss("./testdata/") +} + +func TestStos(t *testing.T) { + t.Log(stos(3661)) +} diff --git a/Reply/F/ass/testdata/0.csv b/Reply/F/ass/testdata/0.csv new file mode 100644 index 0000000..58abb8f --- /dev/null +++ b/Reply/F/ass/testdata/0.csv @@ -0,0 +1,19 @@ +0.906548,0,{"text":"外皮糊层奶油谁不会","style":{"color":"#54eed8","border":false,"mode":0},"time":0} +1.169517,0,{"text":"我觉得苦甜才是高级的甜味","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +2.488067,0,{"text":"《精美》","style":{"color":"#ffffff","border":false,"mode":0},"time":0} +2.495381,0,{"text":"哈哈哈哈","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +3.778851,0,{"text":"这蛋糕真不行吧","style":{"color":"#455ff6","border":false,"mode":0},"time":0} +3.784946,0,{"text":"麦芽巧克力蛋糕真的很难","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +4.764952,0,{"text":"一坨","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +6.382001,0,{"text":"这蛋糕配抹茶 不会腻","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +6.385787,0,{"text":"为什么要配主菜,喷的莫名其妙","style":{"color":"#ffed4f","border":false,"mode":0},"time":0} +8.684833,0,{"text":"棉花糖烤焦了","style":{"color":"#54eed8","border":false,"mode":0},"time":0} +8.695949,0,{"text":"不是啊 你们看蛋糕和夹心都不塌很厉害的","style":{"color":"#e33fff","border":false,"mode":0},"time":0} +8.982743,0,{"text":"很难欣赏","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +9.976656,0,{"text":"一层出问题整个都出问题,外面还要淋糖浆","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +11.572514,0,{"text":"不得不承认。她做的甜品都挺丑的。。","style":{"color":"#ffffff","border":false,"mode":0},"time":0} +12.558437,0,{"text":"[青春烟火静态表情包_比心心]","style":{"color":"#e33fff","border":false,"mode":0},"time":0} +12.566395,0,{"text":"不一定,甜的发齁估计","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +12.876798,0,{"text":"[哈哈room_6154037_73546]","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +12.884833,0,{"text":"巧克力的问题","style":{"color":"#58c1de","border":false,"mode":0},"time":0} +14.168164,0,{"text":"蛋糕店的那些基本很少全部纯手工","style":{"color":"#58c1de","border":false,"mode":0},"time":0} diff --git a/Reply/F/comp.go b/Reply/F/comp.go index eedc547..4c4d702 100644 --- a/Reply/F/comp.go +++ b/Reply/F/comp.go @@ -25,8 +25,8 @@ var DanmuCountPerMin = comp.Get[interface { }](`danmuCountPerMin`) var Ass = comp.Get[interface { - Assf(s string) error - Ass_f(ctx context.Context, enc, savePath string, st time.Time) + ToAss(savePath string) + Init(cfg any) }](`ass`) var Danmuji = comp.Get[interface { diff --git a/Reply/Reply.go b/Reply/Reply.go index a20a0b8..901913d 100644 --- a/Reply/Reply.go +++ b/Reply/Reply.go @@ -346,9 +346,6 @@ func (t replyF) anchor_lot_start(s string) { sh = append(sh, J.Data.AwardName, "开始") } - { //额外 ass - _ = replyFunc.Ass.Assf(fmt.Sprintln("天选之人", J.Data.AwardName, "开始")) - } fmt.Println(sh...) Gui_show(Itos(sh), `0tianxuan`) @@ -380,9 +377,6 @@ func (t replyF) anchor_lot_award(s string) { } } sh = append(sh, "]") - { //额外 ass - _ = replyFunc.Ass.Assf(fmt.Sprintln("天选之人", J.Data.AwardName, "结束")) - } fmt.Println(sh...) Gui_show(Itos(sh), `0tianxuan`) @@ -461,7 +455,6 @@ func (t replyF) user_toast_msg(s string) { }) } { //额外 ass 私信 - _ = replyFunc.Ass.Assf(fmt.Sprintln(sh...)) t.Common.Danmu_Main_mq.Push_tag(`guard_update`, nil) //使用连续付费的新舰长无法区分,刷新舰长数 if msg := t.Common.K_v.LoadV(`上舰私信`).(string); uid != 0 && msg != "" { t.Common.Danmu_Main_mq.Push_tag(`pm`, send.Pm_item{ @@ -891,9 +884,6 @@ func (t replyF) send_gift(s string) { }, }) } - { //额外 - _ = replyFunc.Ass.Assf(fmt.Sprintln(sh...)) - } fmt.Println("\n====") fmt.Println(sh...) fmt.Print("====\n\n") @@ -1051,7 +1041,6 @@ func (t replyF) super_chat_message(s string) { fmt.Print("====\n") { //额外 - _ = replyFunc.Ass.Assf(fmt.Sprintln(sh...)) Gui_show(Itos(sh), "0superchat") //直播流服务弹幕 SendStreamWs(Danmu_item{ @@ -1408,8 +1397,6 @@ func Msg_showdanmu(item Danmu_item) { } //展示 { - //ass - _ = replyFunc.Ass.Assf(item.msg) //直播流服务弹幕 SendStreamWs(item) diff --git a/Reply/stream.go b/Reply/stream.go index cc14009..8da7e82 100644 --- a/Reply/stream.go +++ b/Reply/stream.go @@ -1427,11 +1427,6 @@ func (t *M4SStream) Start() bool { //保存弹幕 go StartRecDanmu(contextC, ms.GetSavePath()) - //ass - if enc, ok := ms.common.K_v.LoadV("Ass编码").(string); ms.common.IsOn(`生成Ass弹幕`) && ms.common.IsOn(`仅保存当前直播间流`) && ok { - go replyFunc.Ass.Ass_f(contextC, enc, ms.GetSavePath(), time.Now()) - } - path := ms.GetSavePath() + `0.` + ms.GetStreamType() startT := time.Now() if e := ms.PusherToFile(contextC, path, startf, stopf); e != nil { @@ -1439,6 +1434,9 @@ func (t *M4SStream) Start() bool { } duration := time.Since(startT) + // Ass + replyFunc.Ass.ToAss(ms.GetSavePath()) + //PusherToFile fin genFastSeed if disableFastSeed, ok := ms.common.K_v.LoadV("禁用快速索引生成").(bool); !ok || !disableFastSeed { type deal interface { diff --git a/bili_danmu.go b/bili_danmu.go index 72d344c..4ee7288 100644 --- a/bili_danmu.go +++ b/bili_danmu.go @@ -94,6 +94,8 @@ func Start() { reply.SaveToJson.Init() // 附加功能 保持牌子点亮 reply.KeepMedalLight(mainCtx, c.C) + //ass初始化 + replyFunc.Ass.Init(c.C.K_v.LoadV("Ass")) // 指定房间录制区间 if _, err := recStartEnd.InitF.Run(mainCtx, c.C); err != nil { danmulog.Base("功能", "指定房间录制区间").L(`E: `, err) diff --git a/demo/config/config_K_v.json b/demo/config/config_K_v.json index 1bc55d8..fc20fea 100644 --- a/demo/config/config_K_v.json +++ b/demo/config/config_K_v.json @@ -178,9 +178,19 @@ "max":-1 } ], - "ass-help": "只有保存直播流时才考虑生成ass,ass编码默认GB18030(可选utf-8)", - "生成Ass弹幕": true, - "Ass编码": "GB18030", + "ass-help": "只有保存直播流时才考虑生成ass,ass编码默认utf-8", + "Ass": { + "fontname-help":"指定字体", + "fontname":"", + "fontsize-help":"字体大小int", + "fontsize":40, + "showSec-help":"弹幕显示时长int,秒", + "showSec":10, + "area-help":"弹幕显示区域,float64,0-1(全屏)", + "area":1.0, + "alpha-help":"弹幕透明度,float64,0(不透明)-1(透明)", + "alpha":0.5 + }, "弹幕处理": "", "弹幕表情": true, "弹幕合并": true, -- 2.39.2