合并默认改用 HEVC、码率按「源片 + 500」自动算;手动合并自动匹配同名弹幕
两件事: 1) 合成码率与编码器 - `mergeBitrate` 填 0(默认)时不再是 ICQ 质量模式,改成「源片视频码率 + 500 Kbps」: 烧进去的文字和头像都是高频细节,和源片同码率会糊,所以留一点余量;探测不到源片 码率时才退回质量模式(QSV 用 ICQ 22、软编用 CRF 18),下限 800 Kbps。 - 编码器默认换成硬件 HEVC:候选顺序 hevc_qsv → h264_qsv → libx264,QSV 可用性按 编码器分别探测(原来只探 h264_qsv)。HEVC 在 mp4 里打 `hvc1` tag,Apple 系 (QuickTime / Safari / iPhone)才认 —— ffmpeg 默认写的是 hev1。 - 日志现在会打印编码器优先顺序和实际用的码率。 实测(90 秒真实素材,源片 3217 kbps,密集弹幕):日志「按源片 3217 kbps + 弹幕 余量 500 kbps 取 3717 kbps」,成片 codec=hevc、tag=hvc1、实际 3.71 Mbps。 之前同样是这份素材:h264 + ICQ22 会飙到 7386 kbps(79 MB),改成自动码率后 43.7 MB,体积小了 45% 画质还更稳。 2) 手动合并自动匹配弹幕 - 新增 `GET /api/merge/danmu?video=…`:按视频名找同目录下的同名弹幕,依次试 `.jsonl`、`.ass`、`.nick.ass`;同目录没有就拿文件名去录播目录再找一次。 - 页面在「手填/粘贴视频后失焦」「多选弹窗选完」「从文件列表多选后」「点预览或 开始合并时弹幕框还空着」这几种情况下都会自动匹配并提示;弹幕框里已经有内容时 绝不动它,找不到也保持为空让用户自己选。 - 填回框里的写法与浏览弹窗一致:录播/成片目录下的只写文件名,别处的写绝对路径。 测试:新增 encode_test.go(编码器顺序、参数与 hvc1、自动码率、ffmpeg 输出解析) 和 TestFindDanmuForVideo(7 个子用例:优先级、录播目录回退、绝对路径、找不到返回空); 仓库外的 verify_danmu_match.py 4 组断言全过,verify_pick.py / verify_settings.py 重跑 无回归;go test ./...、gofmt、go vet 干净。
This commit is contained in:
@@ -80,7 +80,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
||||
"mergeDelVideo": false, // 合并完成后删除源视频
|
||||
"mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl)
|
||||
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
|
||||
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
||||
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算
|
||||
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放
|
||||
"defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源
|
||||
|
||||
@@ -208,7 +208,9 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
||||
|
||||
### 自动合并
|
||||
|
||||
开启`autoMerge`后,主播下播且弹幕和录播都自然结束时,会自动把该场录播和弹幕合成为`xxx_弹幕合并.mp4`,之后按设置删除源视频和弹幕文件,并把成片移动到`mergeOutputDir`。`mergeBitrate`大于 0 时成片使用指定目标码率,可以明显减小成片体积。
|
||||
开启`autoMerge`后,主播下播且弹幕和录播都自然结束时,会自动把该场录播和弹幕合成为`xxx_弹幕合并.mp4`,之后按设置删除源视频和弹幕文件,并把成片移动到`mergeOutputDir`。
|
||||
|
||||
成片码率默认(`mergeBitrate`填 0)按**源片码率 + 500 Kbps**算 —— 烧进去的文字和头像都是高频细节,和源片同码率会糊,所以多给一点余量;源片码率探不到时退回质量模式(QSV 用 ICQ 22、软编用 CRF 18)。想固定体积就自己填一个值。编码器优先用硬件 **HEVC(hevc_qsv)**,同画质比 H.264 省三成左右体积、耗时几乎一样,成片在 mp4 里打`hvc1`标记以便 Apple 系播放;显卡不支持时依次回退到`h264_qsv`、`libx264`。注意 Windows 自带播放器要装 HEVC 扩展才能放,PotPlayer、VLC、mpv 不用。
|
||||
|
||||
直播流中途中断时录制会自动重连并产生新的视频分段,合并会把**所有分段拼成一个完整视频**,弹幕时间轴也按各段时长重新对齐,断流期间的弹幕挪到下一段开头,所以后半场不会丢弹幕。分段时长探测不到时会退化为只合并第一段并给出提示。
|
||||
|
||||
@@ -227,7 +229,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
||||
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
||||
|
||||
- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。
|
||||
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。
|
||||
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上并提示一句;找不到、或者弹幕框里已经有内容时就保持原样,自己手选即可。
|
||||
- **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。
|
||||
- **输出文件名**:可以改,重名时自动加`(2)`之类的后缀,不会覆盖已有文件。
|
||||
- **先预览再合并**:`预览`按钮会先把分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件列出来,确认没问题再点`开始合并`。
|
||||
|
||||
+127
-38
@@ -208,11 +208,14 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
lPrintf("弹幕字号按设置使用 %d(弹幕文件里是 %d)", config.DanmuFontSize, meta.FontSize)
|
||||
meta.FontSize = config.DanmuFontSize
|
||||
}
|
||||
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace)
|
||||
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
|
||||
// 顺便拿到源片码率,用于「合成码率填 0」时的自动码率
|
||||
sourceKbps := 0
|
||||
if burnMode {
|
||||
if vw, vh := probeVideoSize(ffmpegFile, videoFile); vw > 0 && vh > 0 {
|
||||
unifyDanmuSpace(&meta, vw, vh, config.DanmuFontSize)
|
||||
}
|
||||
var vw, vh int
|
||||
vw, vh, sourceKbps = probeVideoStream(ffmpegFile, videoFile)
|
||||
unifyDanmuSpace(&meta, vw, vh, config.DanmuFontSize)
|
||||
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
|
||||
}
|
||||
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
|
||||
relayoutLanes(records, meta)
|
||||
@@ -262,6 +265,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
lPrintWarn("没有任何头像可用,弹幕将只烧录文字,请检查网络后重试合并")
|
||||
}
|
||||
|
||||
// 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式
|
||||
bitrate := config.MergeBitrate + burnBitrate
|
||||
if bitrate == 0 && burnMode {
|
||||
if auto := autoMergeBitrate(sourceKbps); auto > 0 {
|
||||
lPrintf("合成码率填的 0,按源片 %d kbps + 弹幕余量 %d kbps 取 %d kbps", sourceKbps, danmuBitrateMargin, auto)
|
||||
bitrate = auto
|
||||
} else {
|
||||
lPrintWarn("探测不到源片码率,改用默认质量模式(ICQ 22 / CRF 18)")
|
||||
}
|
||||
}
|
||||
|
||||
ctx := &burnContext{
|
||||
ffmpegFile: ffmpegFile,
|
||||
videoFile: videoFile,
|
||||
@@ -269,7 +283,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
offset: offset,
|
||||
meta: meta,
|
||||
avatars: avatarPaths,
|
||||
bitrate: config.MergeBitrate + burnBitrate,
|
||||
bitrate: bitrate,
|
||||
dir: dir,
|
||||
}
|
||||
|
||||
@@ -361,30 +375,66 @@ func unifyDanmuSpace(meta *danmuMeta, vw, vh, fontSizeOverride int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 用 ffmpeg 探测视频分辨率,探测不到返回 0, 0
|
||||
func probeVideoSize(ffmpegFile, path string) (int, int) {
|
||||
// 探测视频流的分辨率和码率(Kbps),拿不到的项返回 0
|
||||
func probeVideoStream(ffmpegFile, path string) (int, int, int) {
|
||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
// 没指定输出文件时 ffmpeg 会返回非 0,但流信息已经打印出来了
|
||||
_ = cmd.Run()
|
||||
|
||||
width, height, kbps := 0, 0, 0
|
||||
for _, line := range strings.Split(buf.String(), "\n") {
|
||||
// 容器的总码率(含音频)先记下来,视频流自己没写码率时用它
|
||||
if strings.Contains(line, "Duration:") {
|
||||
if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
kbps, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 只看视频流,跳过封面图(mjpeg 之类的 attached pic)那几行
|
||||
if !strings.Contains(line, "Video:") || strings.Contains(line, "attached pic") {
|
||||
continue
|
||||
}
|
||||
if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 {
|
||||
w, _ := strconv.Atoi(m[1])
|
||||
h, _ := strconv.Atoi(m[2])
|
||||
return w, h
|
||||
width, _ = strconv.Atoi(m[1])
|
||||
height, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
if m := videoBitrateRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
kbps, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
return width, height, kbps
|
||||
}
|
||||
|
||||
// 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分
|
||||
var videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`)
|
||||
var (
|
||||
// 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分
|
||||
videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`)
|
||||
videoBitrateRe = regexp.MustCompile(`, (\d+) kb/s`)
|
||||
// 容器总码率,形如「Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s」
|
||||
containerBitrateRe = regexp.MustCompile(`bitrate: (\d+) kb/s`)
|
||||
)
|
||||
|
||||
// 合成码率填 0(默认)时的算法:源片码率 + 弹幕余量。
|
||||
// 烧进去的文字和头像都是高频细节,跟源片同样码率会糊,所以留一点余量;
|
||||
// 探测不到源片码率时返回 0,让调用方退回质量模式。
|
||||
func autoMergeBitrate(sourceKbps int) int {
|
||||
if sourceKbps <= 0 {
|
||||
return 0
|
||||
}
|
||||
br := sourceKbps + danmuBitrateMargin
|
||||
if br < minMergeBitrate {
|
||||
br = minMergeBitrate
|
||||
}
|
||||
return br
|
||||
}
|
||||
|
||||
const (
|
||||
// 弹幕层占用的额外码率(Kbps)
|
||||
danmuBitrateMargin = 500
|
||||
// 自动码率的下限,源片码率特别低时也别低于这个值
|
||||
minMergeBitrate = 800
|
||||
)
|
||||
|
||||
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
|
||||
func probeDuration(ffmpegFile, path string) float64 {
|
||||
@@ -704,20 +754,56 @@ type videoEncoder struct {
|
||||
opts []string
|
||||
}
|
||||
|
||||
// 按目标码率构造编码器候选列表,QSV 可用时优先
|
||||
func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
|
||||
var qOpts, xOpts []string
|
||||
if bitrate > 0 {
|
||||
br := strconv.Itoa(bitrate) + "k"
|
||||
qOpts = []string{"-b:v", br}
|
||||
xOpts = []string{"-b:v", br, "-preset", "medium"}
|
||||
} else {
|
||||
qOpts = []string{"-global_quality", "22", "-preset", "medium"}
|
||||
xOpts = []string{"-crf", "18", "-preset", "medium"}
|
||||
// 编码器候选顺序:硬件 HEVC(同画质体积最省)→ 硬件 H.264 → CPU 软编
|
||||
func encoderCandidates(hasHEVCQSV, hasH264QSV bool) []string {
|
||||
names := make([]string, 0, 3)
|
||||
if hasHEVCQSV {
|
||||
names = append(names, "hevc_qsv")
|
||||
}
|
||||
encoders := []videoEncoder{{"libx264", xOpts}}
|
||||
if qsvAvailable(ffmpegFile) {
|
||||
encoders = append([]videoEncoder{{"h264_qsv", qOpts}}, encoders...)
|
||||
if hasH264QSV {
|
||||
names = append(names, "h264_qsv")
|
||||
}
|
||||
return append(names, "libx264")
|
||||
}
|
||||
|
||||
// 本机可用的编码器候选,顺序即优先级
|
||||
func availableEncoders(ffmpegFile string) []string {
|
||||
return encoderCandidates(qsvEncoderAvailable(ffmpegFile, "hevc_qsv"),
|
||||
qsvEncoderAvailable(ffmpegFile, "h264_qsv"))
|
||||
}
|
||||
|
||||
// 某个编码器的输出参数。bitrate 为 0 时用质量模式(ICQ / CRF)
|
||||
func encoderOpts(encoder string, bitrate int) []string {
|
||||
var opts []string
|
||||
isQSV := strings.HasSuffix(encoder, "_qsv")
|
||||
if bitrate > 0 {
|
||||
opts = []string{"-b:v", strconv.Itoa(bitrate) + "k"}
|
||||
if !isQSV {
|
||||
opts = append(opts, "-preset", "medium")
|
||||
}
|
||||
} else if isQSV {
|
||||
opts = []string{"-global_quality", "22", "-preset", "medium"}
|
||||
} else {
|
||||
opts = []string{"-crf", "18", "-preset", "medium"}
|
||||
}
|
||||
// HEVC 在 mp4 里默认写成 hev1,Apple 系(QuickTime / Safari / iPhone)不认,统一用 hvc1
|
||||
if isHEVCCodec(encoder) {
|
||||
opts = append(opts, "-tag:v", "hvc1")
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// 这个编码器是不是 HEVC(hevc_qsv / libx265 / hevc_nvenc 之类)
|
||||
func isHEVCCodec(encoder string) bool {
|
||||
return strings.Contains(encoder, "hevc") || strings.Contains(encoder, "265")
|
||||
}
|
||||
|
||||
// 按目标码率构造编码器候选列表(顺序即回退顺序)
|
||||
func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
|
||||
names := availableEncoders(ffmpegFile)
|
||||
encoders := make([]videoEncoder, 0, len(names))
|
||||
for _, name := range names {
|
||||
encoders = append(encoders, videoEncoder{name: name, opts: encoderOpts(name, bitrate)})
|
||||
}
|
||||
return encoders
|
||||
}
|
||||
@@ -895,31 +981,34 @@ func fetchAvatarURL(uid int64) string {
|
||||
return strings.TrimSpace(out.Profile.HeadURL)
|
||||
}
|
||||
|
||||
// QSV 可用性的探测结果,按 ffmpeg 路径缓存,避免分块时每块都试一次
|
||||
// QSV 编码器的可用性探测结果,按 ffmpeg 路径缓存,避免分块时每块都试一次
|
||||
var qsvCache struct {
|
||||
sync.Mutex
|
||||
path string
|
||||
ok bool
|
||||
done bool
|
||||
path string
|
||||
avail map[string]bool
|
||||
}
|
||||
|
||||
// 探测 Intel QSV(h264_qsv)硬件编码是否可用
|
||||
func qsvAvailable(ffmpegFile string) bool {
|
||||
// 探测某个 Intel QSV 编码器(hevc_qsv / h264_qsv)能不能跑,做一次真实的极小编码
|
||||
func qsvEncoderAvailable(ffmpegFile, encoder string) bool {
|
||||
qsvCache.Lock()
|
||||
if qsvCache.done && qsvCache.path == ffmpegFile {
|
||||
ok := qsvCache.ok
|
||||
qsvCache.Unlock()
|
||||
return ok
|
||||
if qsvCache.avail != nil && qsvCache.path == ffmpegFile {
|
||||
if ok, tested := qsvCache.avail[encoder]; tested {
|
||||
qsvCache.Unlock()
|
||||
return ok
|
||||
}
|
||||
}
|
||||
qsvCache.Unlock()
|
||||
|
||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "color=c=black:s=320x240:d=0.5:r=30",
|
||||
"-c:v", "h264_qsv", "-frames:v", "5", "-f", "null", "-")
|
||||
"-c:v", encoder, "-frames:v", "5", "-f", "null", "-")
|
||||
ok := cmd.Run() == nil
|
||||
|
||||
qsvCache.Lock()
|
||||
qsvCache.path, qsvCache.ok, qsvCache.done = ffmpegFile, ok, true
|
||||
if qsvCache.path != ffmpegFile || qsvCache.avail == nil {
|
||||
qsvCache.path, qsvCache.avail = ffmpegFile, map[string]bool{}
|
||||
}
|
||||
qsvCache.avail[encoder] = ok
|
||||
qsvCache.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
`acfunlive -mirai` 利用 Mirai 发送直播通知到指定 QQ 或 QQ 群
|
||||
|
||||
`acfunlive -danmuburn xxx.jsonl -video xxx.flv` 把弹幕合成到视频里,不指定`-video`时只输出带头像的弹幕层。弹幕文件也接受上游原版的`.ass`(会先还原成 jsonl 再合并),可以用`-out`、`-offset`、`-vbitrate`、`-ffmpeg`分别指定输出文件、时间偏移、目标码率和 ffmpeg 的位置
|
||||
`acfunlive -danmuburn xxx.jsonl -video xxx.flv` 把弹幕合成到视频里,不指定`-video`时只输出带头像的弹幕层。弹幕文件也接受上游原版的`.ass`(会先还原成 jsonl 再合并),可以用`-out`、`-offset`、`-vbitrate`、`-ffmpeg`分别指定输出文件、时间偏移、目标码率和 ffmpeg 的位置。`-vbitrate`不填(或 0)且配置文件里的`mergeBitrate`也是 0 时,按「源片码率 + 500 Kbps」自动算;编码器优先硬件 HEVC(hevc_qsv),依次回退 h264_qsv、libx264
|
||||
|
||||
`acfunlive -listlive` 列出正在直播的主播
|
||||
|
||||
|
||||
+5
-1
@@ -80,7 +80,9 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
|
||||
|
||||
口令支持任意字符,包括中文——不过 HTTP 请求头只接受 ISO-8859-1,浏览器遇到非 ASCII 的口令会直接拒绝发请求(报`String contains non ISO-8859-1 code point`),所以**页面发给服务端的`X-Auth-Token`是百分号编码过的口令**(`encodeURIComponent`)。服务端两种写法都认:先拿原样比,再拿解码后的比,所以手写 curl 或者脚本按原样把中文口令放进请求头、`Authorization: Bearer`、`?token=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。
|
||||
|
||||
跟手动合并有关的是下面两个:
|
||||
跟手动合并有关的是下面三个:
|
||||
`GET /api/merge/danmu?video=<视频文件名或路径>` 按视频名找同目录下的同名弹幕文件,给页面「选好视频自动匹配弹幕」用。依次试`.jsonl`、`.ass`、`.nick.ass`,同目录没有就拿文件名去录播目录再找一次;返回`{"danmu":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。
|
||||
|
||||
`POST /api/merge/info` 预览一次手动合并的计划,不落盘。
|
||||
|
||||
```
|
||||
@@ -99,6 +101,8 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
|
||||
|
||||
`POST /api/config`里的`danmuFontSize`控制合并时弹幕文字的字号(像素),头像按同一比例缩放,填 0 表示沿用弹幕文件里记的字号。渲染前会按最终字号重排弹道:从上游原版`.ass`还原出来的弹幕只按内容宽度占位,而渲染用的是「昵称(uid):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
|
||||
|
||||
`mergeBitrate`填 0(默认)时按「源片码率 + 500 Kbps」自动计算:先探测视频流的码率,加 500 留给烧进去的弹幕;探测不到就退回质量模式。填大于 0 的值则按该码率固定输出。编码器优先硬件 HEVC(`hevc_qsv`,同画质比 H.264 省约三成体积,mp4 里打`hvc1`标记),不支持时回退`h264_qsv`、`libx264`,日志里会打印实际用的那一个。
|
||||
|
||||
`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。
|
||||
|
||||
```
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 编码器候选顺序:硬件 HEVC 最省 → 硬件 H.264 → CPU 软编
|
||||
func TestEncoderCandidatesOrder(t *testing.T) {
|
||||
cases := []struct {
|
||||
hevc, h264 bool
|
||||
want []string
|
||||
}{
|
||||
{true, true, []string{"hevc_qsv", "h264_qsv", "libx264"}},
|
||||
{false, true, []string{"h264_qsv", "libx264"}},
|
||||
{true, false, []string{"hevc_qsv", "libx264"}},
|
||||
{false, false, []string{"libx264"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := encoderCandidates(c.hevc, c.h264)
|
||||
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
||||
t.Errorf("hevc=%v h264=%v 应为 %v,实际 %v", c.hevc, c.h264, c.want, got)
|
||||
}
|
||||
}
|
||||
// 候选列表必须能真的跑出结果,最后一个永远是纯 CPU 的 libx264
|
||||
last := encoderCandidates(true, true)
|
||||
if last[len(last)-1] != "libx264" {
|
||||
t.Errorf("最后一个候选必须是 libx264,实际 %s", last[len(last)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// 编码器参数:固定码率 / 质量模式,HEVC 必须带 hvc1 tag(Apple 系才认)
|
||||
func TestEncoderOpts(t *testing.T) {
|
||||
join := func(ss []string) string { return strings.Join(ss, " ") }
|
||||
|
||||
cases := []struct {
|
||||
encoder string
|
||||
bitrate int
|
||||
want []string
|
||||
}{
|
||||
{"hevc_qsv", 4000, []string{"-b:v", "4000k", "-tag:v", "hvc1"}},
|
||||
{"h264_qsv", 4000, []string{"-b:v", "4000k"}},
|
||||
{"libx264", 4000, []string{"-b:v", "4000k", "-preset", "medium"}},
|
||||
{"hevc_qsv", 0, []string{"-global_quality", "22", "-preset", "medium", "-tag:v", "hvc1"}},
|
||||
{"libx265", 0, []string{"-crf", "18", "-preset", "medium", "-tag:v", "hvc1"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := encoderOpts(c.encoder, c.bitrate)
|
||||
if join(got) != join(c.want) {
|
||||
t.Errorf("%s @ %d 应为 %v,实际 %v", c.encoder, c.bitrate, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 「合成码率」填 0 时的算法:源片码率 + 弹幕余量,探测不到就交回 0(走质量模式)
|
||||
func TestAutoMergeBitrate(t *testing.T) {
|
||||
cases := []struct {
|
||||
source int
|
||||
want int
|
||||
}{
|
||||
{3197, 3697}, // 用户那场录播的源片码率
|
||||
{0, 0}, // 探测不到 → 质量模式
|
||||
{-1, 0},
|
||||
{100, 800}, // 低于下限时抬到下限
|
||||
{3000, 3500}, // 正常情况就是 +500
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := autoMergeBitrate(c.source); got != c.want {
|
||||
t.Errorf("源码率 %d 应得 %d,实际 %d", c.source, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpeg 输出的解析:分辨率靠「逗号 + 数字x数字」,码率靠视频流那一行的 kb/s,
|
||||
// 视频流没写码率时退回容器总码率
|
||||
func TestProbeStreamRegex(t *testing.T) {
|
||||
sizeLine := " Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 3217 kb/s, 23.98 fps"
|
||||
m := videoSizeRe.FindStringSubmatch(sizeLine)
|
||||
if len(m) != 3 {
|
||||
t.Fatalf("没解析出分辨率:%s", sizeLine)
|
||||
}
|
||||
w, _ := strconv.Atoi(m[1])
|
||||
h, _ := strconv.Atoi(m[2])
|
||||
if w != 1920 || h != 1080 {
|
||||
t.Errorf("分辨率解析成 %dx%d", w, h)
|
||||
}
|
||||
// 视频流那一行里的码率(不是编码器名里的 0x…)
|
||||
if b := videoBitrateRe.FindStringSubmatch(sizeLine); len(b) != 2 || b[1] != "3217" {
|
||||
t.Errorf("视频流码率没解析对:%v", b)
|
||||
}
|
||||
|
||||
portrait := " Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, smpte170m/unknown/unknown, progressive), 720x1280 [SAR 1:1 DAR 9:16], 1570 kb/s, 10 fps"
|
||||
m = videoSizeRe.FindStringSubmatch(portrait)
|
||||
if len(m) != 3 || m[1] != "720" || m[2] != "1280" {
|
||||
t.Errorf("竖屏分辨率没解析对:%v", m)
|
||||
}
|
||||
|
||||
// 视频流那行没写码率时用容器总码率兜底
|
||||
durLine := " Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s"
|
||||
if m := containerBitrateRe.FindStringSubmatch(durLine); len(m) != 2 || m[1] != "1767" {
|
||||
t.Errorf("容器码率没解析对:%v", m)
|
||||
}
|
||||
}
|
||||
+1
-29
@@ -1,9 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// 还原自 .ass 的弹幕:原格式的弹道是按「只有内容」的宽度占位的,渲染用的是
|
||||
// 「昵称(uid):内容 + 头像」的宽度,占位更长 → 同弹道会追尾。重排后必须分开。
|
||||
@@ -167,31 +164,6 @@ func TestUnifyDanmuSpace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpeg 输出的解析:分辨率要靠「逗号 + 数字x数字」,别被编码器名里的 0x… 骗到
|
||||
func TestProbeVideoSizeRegex(t *testing.T) {
|
||||
cases := []struct {
|
||||
line string
|
||||
w, h int
|
||||
}{
|
||||
{" Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 1500 kb/s, 23.98 fps", 1920, 1080},
|
||||
{" Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, smpte170m/unknown/unknown, progressive), 720x1280 [SAR 1:1 DAR 9:16], 1570 kb/s, 10 fps", 720, 1280},
|
||||
{" Stream #0:0: Video: hevc (Main) (hvc1 / 0x31637668), yuv420p(tv), 3840x2160, 8000 kb/s", 3840, 2160},
|
||||
{" Stream #0:0(und): Video: h264 (High), yuv420p, 640x480, 30 fps", 640, 480},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := videoSizeRe.FindStringSubmatch(c.line)
|
||||
if len(m) != 3 {
|
||||
t.Errorf("没解析出行:%s", c.line)
|
||||
continue
|
||||
}
|
||||
w, _ := strconv.Atoi(m[1])
|
||||
h, _ := strconv.Atoi(m[2])
|
||||
if w != c.w || h != c.h {
|
||||
t.Errorf("解析成 %dx%d,应为 %dx%d:%s", w, h, c.w, c.h, c.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按渲染时的公式统计同弹道追尾条数
|
||||
func countLaneOverlaps(records []danmuRecord, meta danmuMeta) int {
|
||||
show := int64(danmuShowSeconds * 1e9)
|
||||
|
||||
@@ -29,7 +29,7 @@ func argsHandle() {
|
||||
burnOut := flag.String("out", "", "合成输出文件路径,默认与弹幕文件同目录")
|
||||
burnOffset := flag.Float64("offset", 0, "录播视频相对弹幕录制开始的时间偏移,单位秒,配合 -danmuburn -video 使用")
|
||||
burnFFmpeg := flag.String("ffmpeg", "", "ffmpeg 可执行文件路径;不指定时读设置文件里的 ffmpegPath,仍旧找不到再按常见位置查找")
|
||||
burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为默认质量,配合 -danmuburn 使用")
|
||||
burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为按「源片码率 + 500」自动计算,配合 -danmuburn 使用")
|
||||
flag.Parse()
|
||||
|
||||
if *isHelp {
|
||||
|
||||
@@ -119,6 +119,52 @@ func resolveMergeInput(value string) (string, error) {
|
||||
return "", fmt.Errorf("找不到文件:%s", value)
|
||||
}
|
||||
|
||||
// 找同名弹幕文件时按这个顺序试:本程序录的 .jsonl 带头像链接,优先用
|
||||
var mergeDanmuCandidates = []string{".jsonl", ".ass", ".nick.ass"}
|
||||
|
||||
// 按视频文件找同目录下的同名弹幕文件,找不到返回空字符串。
|
||||
// 同目录找不到时再拿文件名去录播目录里试一次(视频被挪走、弹幕还留在录播目录的情况)
|
||||
func findDanmuForVideo(video string) string {
|
||||
full, err := resolveMergeInput(video)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSuffix(full, filepath.Ext(full))
|
||||
dirs := []string{filepath.Dir(full)}
|
||||
if recDir := recordSaveDir(); normalizePath(recDir) != normalizePath(filepath.Dir(full)) {
|
||||
dirs = append(dirs, recDir)
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
for _, ext := range mergeDanmuCandidates {
|
||||
cand := filepath.Join(dir, filepath.Base(base)+ext)
|
||||
if info, err := os.Stat(cand); err == nil && !info.IsDir() {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 填回输入框时的写法:录播目录/成片目录里的只写文件名,别处的写绝对路径(和浏览弹窗一致)
|
||||
func mergeFillValue(path string) string {
|
||||
dir := normalizePath(filepath.Dir(path))
|
||||
if dir == normalizePath(recordSaveDir()) || dir == normalizePath(mergeSaveDir()) {
|
||||
return filepath.Base(path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// GET /api/merge/danmu?video=xxx —— 页面选好视频后自动找同名弹幕文件用
|
||||
func mergeDanmuMatchHandler(w http.ResponseWriter, r *http.Request) {
|
||||
path := findDanmuForVideo(r.URL.Query().Get("video"))
|
||||
resp := map[string]string{"danmu": ""}
|
||||
if path != "" {
|
||||
resp["danmu"] = mergeFillValue(path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// 扩展名白名单的说明文字
|
||||
func extListText(m map[string]struct{}) string {
|
||||
out := make([]string, 0, len(m))
|
||||
|
||||
@@ -718,3 +718,119 @@ Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,乙(202),0,0,0,,{\move(400,48,-40,48)}
|
||||
}
|
||||
t.Logf("多段成片 %s(%.2f 秒)", filepath.Base(plan.out), d)
|
||||
}
|
||||
|
||||
// 手动合并:按视频文件名自动找同目录下的同名弹幕文件
|
||||
func TestFindDanmuForVideo(t *testing.T) {
|
||||
savedConfig := config
|
||||
oldRecordDir := recordDir
|
||||
savedMerge := config.MergeOutputDir
|
||||
defer func() {
|
||||
config = savedConfig
|
||||
recordDir = oldRecordDir
|
||||
config.MergeOutputDir = savedMerge
|
||||
}()
|
||||
|
||||
// 每个用例一套独立的目录环境
|
||||
prepare := func(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
config.Directory = dir
|
||||
config.RecordSubdir = defaultRecordSubdir
|
||||
config.MergeSubdir = defaultMergeSubdir
|
||||
config.MergeOutputDir = ""
|
||||
if recordDir == nil {
|
||||
recordDir = new(string)
|
||||
}
|
||||
*recordDir = dir
|
||||
recDir := recordSaveDir()
|
||||
if err := os.MkdirAll(recDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir, recDir
|
||||
}
|
||||
|
||||
t.Run("优先用 jsonl", func(t *testing.T) {
|
||||
_, recDir := prepare(t)
|
||||
writeTestFile(t, filepath.Join(recDir, "a.mp4"), "v")
|
||||
writeTestFile(t, filepath.Join(recDir, "a.ass"), "ass")
|
||||
writeTestFile(t, filepath.Join(recDir, "a.jsonl"), "jsonl")
|
||||
// 录播目录里的只填文件名,方便以后改保存目录
|
||||
if got := findDanmuForVideo("a.mp4"); got != filepath.Join(recDir, "a.jsonl") {
|
||||
t.Errorf("应优先 .jsonl,实际 %s", got)
|
||||
}
|
||||
if got := mergeFillValue(filepath.Join(recDir, "a.jsonl")); got != "a.jsonl" {
|
||||
t.Errorf("录播目录里的应只写文件名,实际 %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没有 jsonl 时用 ass", func(t *testing.T) {
|
||||
_, recDir := prepare(t)
|
||||
writeTestFile(t, filepath.Join(recDir, "b.mp4"), "v")
|
||||
writeTestFile(t, filepath.Join(recDir, "b.ass"), "ass")
|
||||
if got := findDanmuForVideo("b.mp4"); got != filepath.Join(recDir, "b.ass") {
|
||||
t.Errorf("应回退到 .ass,实际 %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("只有昵称头像版 ass 也能认", func(t *testing.T) {
|
||||
_, recDir := prepare(t)
|
||||
writeTestFile(t, filepath.Join(recDir, "c.mp4"), "v")
|
||||
writeTestFile(t, filepath.Join(recDir, "c.nick.ass"), "ass")
|
||||
if got := findDanmuForVideo("c.mp4"); got != filepath.Join(recDir, "c.nick.ass") {
|
||||
t.Errorf("应认 .nick.ass,实际 %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("视频在别处时用同目录的弹幕并填绝对路径", func(t *testing.T) {
|
||||
_, _ = prepare(t)
|
||||
other := t.TempDir()
|
||||
video := filepath.Join(other, "d.mp4")
|
||||
writeTestFile(t, video, "v")
|
||||
writeTestFile(t, filepath.Join(other, "d.jsonl"), "jsonl")
|
||||
got := findDanmuForVideo(video)
|
||||
if got != filepath.Join(other, "d.jsonl") {
|
||||
t.Errorf("应找到同目录的弹幕,实际 %s", got)
|
||||
}
|
||||
if fill := mergeFillValue(got); fill != got {
|
||||
t.Errorf("不在录播/成片目录时应填绝对路径,实际 %s", fill)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("同目录没有就看录播目录", func(t *testing.T) {
|
||||
_, recDir := prepare(t)
|
||||
writeTestFile(t, filepath.Join(recDir, "e.jsonl"), "jsonl")
|
||||
other := t.TempDir()
|
||||
video := filepath.Join(other, "e.mp4")
|
||||
writeTestFile(t, video, "v")
|
||||
got := findDanmuForVideo(video)
|
||||
if got != filepath.Join(recDir, "e.jsonl") {
|
||||
t.Errorf("应回退到录播目录,实际 %s", got)
|
||||
}
|
||||
if fill := mergeFillValue(got); fill != "e.jsonl" {
|
||||
t.Errorf("录播目录里的应只写文件名,实际 %s", fill)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("找不到就返回空", func(t *testing.T) {
|
||||
_, recDir := prepare(t)
|
||||
writeTestFile(t, filepath.Join(recDir, "f.mp4"), "v")
|
||||
if got := findDanmuForVideo("f.mp4"); got != "" {
|
||||
t.Errorf("没有同名弹幕时应返回空,实际 %s", got)
|
||||
}
|
||||
if got := findDanmuForVideo("不存在的视频.mp4"); got != "" {
|
||||
t.Errorf("视频本身找不到时应返回空,实际 %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("成片目录里的也写文件名", func(t *testing.T) {
|
||||
dir, _ := prepare(t)
|
||||
mergeDir := mergeSaveDir()
|
||||
if err := os.MkdirAll(mergeDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(mergeDir, "g.jsonl"), "jsonl")
|
||||
if fill := mergeFillValue(filepath.Join(dir, config.MergeSubdir, "g.jsonl")); fill != "g.jsonl" {
|
||||
t.Errorf("成片目录里的应只写文件名,实际 %s", fill)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ const webuiHTML = `<!DOCTYPE html>
|
||||
<button class="ghost" onclick="openPick('mergeOutputDir','dir')">浏览…</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
|
||||
<div class="f"><label>合成码率(Kbps,0 = 源片码率 + 500)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500,填 0 自动"></div>
|
||||
<div class="f"><label>弹幕字号(像素,0 = 跟随弹幕文件)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="例如 42,头像随字号一起缩放"></div>
|
||||
</div>
|
||||
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
|
||||
@@ -325,7 +325,7 @@ const webuiHTML = `<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f">
|
||||
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
|
||||
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl;选了视频会自动找同名的)</label>
|
||||
<div class="pathf">
|
||||
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
|
||||
<button class="ghost" onclick="openPick('mrgDanmu','file')">浏览…</button>
|
||||
@@ -778,6 +778,8 @@ async function saveSettings(b, autoSave){
|
||||
["recordFormat","meowMsgType"].forEach(function(id){
|
||||
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
|
||||
});
|
||||
// 手动合并的视频框:手填/粘贴完(失焦)也自动找一次同名弹幕文件
|
||||
document.getElementById("mrgVideos").addEventListener("change", function(){ autoMatchDanmu(); });
|
||||
// HTML 高度只对 html 类型有意义,选 text 时收起来
|
||||
function toggleMeowHeight(){
|
||||
var isHTML = document.getElementById("meowMsgType").value === "html";
|
||||
@@ -984,6 +986,22 @@ function paintPickSel(){
|
||||
updatePickBar();
|
||||
}
|
||||
function pickClearSel(){ pickSel = []; paintPickSel(); }
|
||||
// 选好视频后自动找同目录同名的弹幕文件(.jsonl / .ass),找不到就让用户自己选。
|
||||
// 弹幕框里已经有内容时不动它,避免把用户填好的覆盖掉
|
||||
async function autoMatchDanmu(){
|
||||
var vEl = document.getElementById("mrgVideos"), dEl = document.getElementById("mrgDanmu");
|
||||
if(!vEl || !dEl || dEl.value.trim()) return;
|
||||
var lines = vEl.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
|
||||
if(!lines.length) return;
|
||||
var r = null;
|
||||
try{ r = await api("/api/merge/danmu?video=" + encodeURIComponent(lines[0])); }catch(e){}
|
||||
if(!r || !r.ok) return;
|
||||
var d = null;
|
||||
try{ d = await r.json(); }catch(e){ return; }
|
||||
if(!d || !d.danmu) return;
|
||||
dEl.value = d.danmu;
|
||||
showMsg(true, "已按视频文件名自动匹配弹幕:" + d.danmu);
|
||||
}
|
||||
// 录播目录下的文件就只填文件名,框里清爽,保存目录以后改了也还认得;别处的填绝对路径
|
||||
function pickFillValue(p){
|
||||
if(!recDir) return p;
|
||||
@@ -1009,6 +1027,7 @@ function pickConfirm(){
|
||||
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
|
||||
}
|
||||
closePick();
|
||||
if(pickField === "mrgVideos") autoMatchDanmu();
|
||||
return;
|
||||
}
|
||||
var v = pickKind === "file" ? pickFile : pickCur;
|
||||
@@ -1098,7 +1117,10 @@ function pickVideos(){
|
||||
var names = [];
|
||||
Array.prototype.forEach.call(document.getElementById("mrgVideoPick").options, function(o){ if(o.selected) names.push(o.value); });
|
||||
names.sort(); // 按文件名里的录制时间排,点选顺序无所谓
|
||||
if(names.length) document.getElementById("mrgVideos").value = names.join("\n");
|
||||
if(names.length){
|
||||
document.getElementById("mrgVideos").value = names.join("\n");
|
||||
autoMatchDanmu();
|
||||
}
|
||||
}
|
||||
function setMergeState(){
|
||||
var go = document.getElementById("mrgGo");
|
||||
@@ -1108,6 +1130,8 @@ function setMergeState(){
|
||||
document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见「监控」页的运行日志" : "";
|
||||
}
|
||||
async function checkMerge(){
|
||||
// 弹幕框还空着就先按视频文件名自动找一次(选了视频直接点检查也能用)
|
||||
if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu();
|
||||
var body = mrgPayload();
|
||||
if(!body) return;
|
||||
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
|
||||
@@ -1131,6 +1155,7 @@ async function checkMerge(){
|
||||
document.getElementById("mrgInfo").textContent = lines.join("\n");
|
||||
}
|
||||
async function startMerge(){
|
||||
if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu();
|
||||
var body = mrgPayload();
|
||||
if(!body) return;
|
||||
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
|
||||
@@ -1841,6 +1866,7 @@ func startWebUI(ctx context.Context) {
|
||||
mux.HandleFunc("GET /api/file", fileDownloadHandler)
|
||||
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
|
||||
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
|
||||
mux.HandleFunc("GET /api/merge/danmu", mergeDanmuMatchHandler)
|
||||
mux.HandleFunc("POST /api/merge", mergeHandler)
|
||||
|
||||
srvCtx, srvCancel := context.WithCancel(ctx)
|
||||
|
||||
Reference in New Issue
Block a user