Files
acfunlive-remake/manualmerge.go
T
tamakyi 6f3c8096b4 修分块渲染的弹幕轨迹:跨块续画 + 文字头像同源,并补失败推送与输出帧率设置
1. 密集弹幕时头像偏离轨迹、弹幕突然加速、之后一段时间屏幕空白

   分块渲染的固有问题,三个毛病叠在一起:

   - writeChunkAss 把 \move 的起止时间截断到块边界(End=min(t0+10, chunk.end)),
     但 x2 写死成 -Width —— 等于把 10 秒的路挤进可见的那零点几秒里,文字直接飞过去。
     而头像 overlay 的 x 一直是「固定 /10 秒 + 真实 t0」,于是同一时刻文字已经走到
     左边、头像还在半路,看起来就是「头像掉队(移动变慢)」。
   - 块尾还会被单块上限收紧成「下一条弹幕出现的时间」,最极端时窗口只剩 0.01 秒。
   - 每块只画「本块新出现」的弹幕,跨块的续画没有:块头屏幕上只剩刚出现的几条,
     要 10 秒才重新填满 —— 就是「弹幕突然加速,然后一段时间屏幕上什么都没有」。

   改法:文字层改成按绝对时间算轨迹(danmuTextX / danmuAvatarX / avatarXExpr 同源),
   任何一块里的文字速度都等于真实速度;分块器改成「续画(起播时间在 start-10 ~ start)
   + 本块新出现的」,块尾取「下一条弹幕的起播时间」,这样每条弹幕只在唯一一块里算
   新出现,显示期间跨过的后面几块把它接着画,一帧都不缺。

   代价是每块要多画一倍多的弹幕,所以顺手把头像一个「绝对路径 + 每人一行 scale」改成
   「预缩放 + 短相对路径」(prepareAvatarInputs / scaleAvatarFile),每条从实测 241
   字符降到约 130,单块上限才能从 120 抬到 200(200 条 ≈ 26600,仍低于自检阈值 30000
   和 Windows 的 32767)。

2. 合并失败没有任何通知

   notify.go 新增 mergeFail 事件与 notifyMergeFailure(),接在 autoMerge() 和手动合并的
   失败分支上。正常结束、文件里没有弹幕、以及用户自己点的停止都不算失败,不推 ——
   最后一种推「合并失败」会误导。配置项 notifyMergeFail 默认开,且和 notifyEnabled 一样
   在 Unmarshal 之前预置 true,免得老设置文件升级后被静默关掉。

3. 低帧率录播里的弹幕一格一格跳

   新增设置项 mergeFps,默认 0 = 跟随源片(什么都不做)。只在目标高于源片帧率时才加
   fps 滤镜,而且必须加在字幕/头像之前 —— 加在之后只是把已经画好的几帧复制一遍,
   看起来还是卡。上限 240。

4. 顺带

   - -danmuburn 命令行模式不走 initialize(),exeDir 从来没被赋值:头像缓存会落到
     「当前工作目录/avatar_cache」,旧版还会把这个相对路径写进滤镜,而 ffmpeg 的工作
     目录是弹幕目录,报「打不开 avatar_cache\xxx.png」整块失败。
   - cleanTempFiles 见到目录就 continue,预缩放头像那个临时文件夹永远清不掉。
   - command_len_test 不再用会过期的「每条 241 字符」估算,改成真跑一遍最坏情况;
     探针的头像路径也改成真实的短相对路径(原来量的是早已不存在的长绝对路径)。
   - 新增 TestWebuiSettingFieldsMatchInputs:把页面里 JS 的 settingFields/numberFields
     抠出来,跟所有 input/select/textarea 的 id 比对,漏登记的设置项会被抓住。

验证

- gofmt / go vet / go test(174 个)/ go test -race / staticcheck / GOOS=linux go build 全过。
- 反向验证 32 项全过:把每个修复临时退回,对应测试确实失败。
- 真实片段实测(2023AcFun 晚会 120 秒高峰段,689 条弹幕,10 秒窗口内最多同时 140 条):
  旧 6 块 / 绘制 689 次 / 54.1% 的弹幕文字被加速(中位 2.12x,最差 500x)/
  54.2% 有轨迹缺口(中位缺 5.29 秒)/ 78.3s;
  新 5 块 / 绘制 984 次 / 加速 0% / 缺口 0% / 98.8s(+26%)。
  对照帧里旧版在块尾前 0.1 秒屏幕已经全空,块头后 0.4 秒仍然全空;新版一直是满屏。
- 输出帧率:15fps 源配 mergeFps=60,成片 60fps,1 秒内 60 帧哈希互不相同(不是复制帧);
  默认设置下成片仍是 15fps。
2026-09-26 16:34:47 +08:00

669 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// web UI 的手动合并:自己挑视频和弹幕合成一个成片
// 自动合并只在录制自然结束时触发(手动停止过、或者弹幕和视频对不上号的都不会合并),
// 这里补上那条路:选文件 → 检查 → 合成
package main
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// 允许作为合并输入的视频扩展名
var mergeVideoExts = map[string]struct{}{
".mp4": {}, ".m4v": {}, ".flv": {}, ".ts": {}, ".mkv": {}, ".mov": {}, ".webm": {},
}
// 允许作为合并输入的弹幕扩展名
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
// 手动合并的请求体
type mergeRequest struct {
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名
}
// 检查结果里每个视频的信息
type mergeVideoInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Duration float64 `json:"duration"`
InUse bool `json:"inUse"`
}
// 手动合并的检查结果,页面直接展示
type mergeInfo struct {
Videos []mergeVideoInfo `json:"videos"`
DanmuName string `json:"danmuName"`
DanmuCount int `json:"danmuCount"`
DanmuFirst float64 `json:"danmuFirst"`
DanmuLast float64 `json:"danmuLast"`
DanmuHasAvatar bool `json:"danmuHasAvatar"`
Source string `json:"source"` // 弹幕数据的来源说明(jsonl 还是从 ass 还原)
Offset float64 `json:"offset"`
OffsetNote string `json:"offsetNote"`
OutName string `json:"outName"`
Duration float64 `json:"duration"` // 视频总时长(秒)
Warnings []string `json:"warnings"`
}
// 检查通过后解析好的合并方案,避免检查一次、合成时又重新解析一遍
type mergePlan struct {
videos []string // 视频的绝对路径,已按录制时间排好序
danmu string // 弹幕文件的绝对路径
out string // 成片的绝对路径
meta danmuMeta
offset *float64
info mergeInfo
}
// 手动合并的运行状态,页面轮询它来决定按钮是否可用
var mergeRun struct {
sync.Mutex
busy bool
outName string
lastErr string
doneAt int64
}
// 手动合并是否正在进行
func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) {
mergeRun.Lock()
defer mergeRun.Unlock()
return mergeRun.busy, mergeRun.outName, mergeRun.lastErr, mergeRun.doneAt
}
// 解析用户填的路径:可以先当文件名在录播/成片文件夹里找,也可以填绝对或相对路径
func resolveMergeInput(value string) (string, error) {
value = strings.TrimSpace(value)
// 去掉从资源管理器复制路径时带上的引号
value = strings.Trim(value, `"'`)
if value == "" {
return "", errors.New("文件路径为空")
}
if value == "." || value == ".." || strings.ContainsAny(value, "\x00\r\n") {
return "", errors.New("文件路径不合法")
}
var cands []string
if filepath.IsAbs(value) {
cands = append(cands, value)
} else {
// 不是绝对路径时优先当成录播目录里的文件名,这样页面下拉里选出来的名字能直接填
cands = append(cands, filepath.Join(recordSaveDir(), value))
if mergeDir := mergeSaveDir(); normalizePath(mergeDir) != normalizePath(recordSaveDir()) {
cands = append(cands, filepath.Join(mergeDir, value))
}
cands = append(cands, value) // 相对当前工作目录
}
for _, c := range cands {
if info, err := os.Stat(c); err == nil && !info.IsDir() {
full, err := filepath.Abs(c)
if err != nil {
continue
}
return full, nil
}
}
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))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return strings.Join(out, " ")
}
// 从文件名前缀推出录制开始的时刻
// 本程序(以及上游原版)的文件名都是「2006-01-02 15-04-05 主播名 标题」
func nameStartNs(path string) int64 {
name := filepath.Base(path)
if len(name) < 19 {
return 0
}
t, err := time.ParseInLocation("2006-01-02 15-04-05", name[:19], time.Local)
if err != nil {
return 0
}
return t.UnixNano()
}
// 文件名里没有时间戳时,用「修改时间 - 时长」估算视频的起点
func estimateStartNs(ffmpegFile, path string) int64 {
d := probeDuration(ffmpegFile, path)
if d <= 0 {
return 0
}
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.ModTime().UnixNano() - int64(d*1e9)
}
// 推测弹幕相对视频起点的时间偏移:视频时间 = 弹幕时间 + 偏移
func guessOffset(videos []string, danmuPath string, meta danmuMeta) (float64, string) {
if len(videos) == 1 {
videoBase := strings.TrimSuffix(filepath.Base(videos[0]), filepath.Ext(videos[0]))
if strings.EqualFold(danmuBaseName(danmuPath), videoBase) {
return 0, "视频和弹幕文件名一致,按同一场录制处理"
}
}
danmuStart, fromMeta := nameStartNs(danmuPath), false
if danmuStart == 0 && meta.StartTime > 0 {
danmuStart, fromMeta = meta.StartTime, true
}
if danmuStart == 0 {
return 0, "弹幕文件里没有录制开始时间,偏移按 0 处理"
}
videoStart := int64(0)
if len(videos) > 0 {
videoStart = nameStartNs(videos[0])
if videoStart == 0 {
videoStart = estimateStartNs(getFFmpeg(), videos[0])
}
}
if videoStart == 0 {
return 0, "无法确定视频的开始时间,偏移按 0 处理"
}
off := float64(danmuStart-videoStart) / 1e9
note := "按文件名里的录制时间推算"
if fromMeta {
note = "视频按文件名时间、弹幕按 jsonl 里记录的开始时间推算"
}
if math.Abs(off) > 60 {
note += ";偏移偏大,请确认选的是同一场录制"
}
return off, note
}
// 准备弹幕源:.jsonl 直接用;.ass / .nick.ass 还原成 jsonl
// write 为 false 时只解析不落盘(页面上的「检查」用)
type danmuSource struct {
jsonlPath string
meta danmuMeta
count int
first float64
last float64
hasAvatar bool
source string // 来源说明
// 从 .ass 还原出来的结果写不进原目录、退到系统临时目录时置位:
// 这种文件调用方用完必须删掉,别在临时目录里越攒越多
tempJsonl bool
}
func prepareDanmuSource(path string, write bool) (danmuSource, error) {
if strings.EqualFold(filepath.Ext(path), ".jsonl") {
sum, err := scanJsonlSummary(path)
if err != nil {
return danmuSource{}, err
}
return danmuSource{jsonlPath: path, meta: sum.meta, count: sum.count,
first: sum.first, last: sum.last, hasAvatar: sum.hasAvatar,
source: "jsonl 弹幕数据"}, nil
}
// 旁边已经有同名的 jsonl(本程序录制的就有),它带着头像链接,比从 .ass 还原更好
jsonlPath := assSiblingJsonl(path)
if info, err := os.Stat(jsonlPath); err == nil && info.Size() > 0 {
if sum, err := scanJsonlSummary(jsonlPath); err == nil && sum.count > 0 {
lPrintf("发现同名的弹幕数据文件 %s,直接用(比 .ass 多了头像链接)", filepath.Base(jsonlPath))
return danmuSource{jsonlPath: jsonlPath, meta: sum.meta, count: sum.count,
first: sum.first, last: sum.last, hasAvatar: sum.hasAvatar,
source: "同名的 jsonl 弹幕数据"}, nil
}
}
meta, records, err := parseAssFile(path)
if err != nil {
return danmuSource{}, err
}
src := danmuSource{jsonlPath: jsonlPath, meta: meta, count: len(records),
first: records[0].Time, last: records[len(records)-1].Time,
source: "由 " + filepath.Base(path) + " 还原"}
if !write {
return src, nil
}
if err := writeJsonlFile(jsonlPath, meta, records); err != nil {
// 目录只读等情况退到临时目录,不影响这次合并
tmp := filepath.Join(os.TempDir(), chunkTempPrefix+"converted.jsonl")
if err2 := writeJsonlFile(tmp, meta, records); err2 != nil {
return src, fmt.Errorf("还原弹幕数据失败:%v", err)
}
lPrintWarnf("无法把还原结果写入 %s(%v),改用临时文件 %s", jsonlPath, err, tmp)
src.jsonlPath = tmp
src.tempJsonl = true
return src, nil
}
lPrintf("已把 %s 还原成 %s", filepath.Base(path), filepath.Base(jsonlPath))
return src, nil
}
// .ass 还原结果退到系统临时目录时,用完就删。只删标记过的临时文件:
// 正常情况 jsonlPath 是用户录播目录里的同名 jsonl,删了就是丢数据
func dropTempDanmu(src danmuSource) {
if src.tempJsonl {
_ = os.Remove(src.jsonlPath)
}
}
// 输出文件名只能是文件名本身,且固定为 mp4
func sanitizeOutName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", errors.New("输出文件名不能为空")
}
if name == "." || name == ".." || strings.ContainsAny(name, "\x00\r\n") ||
name != filepath.Base(filepath.FromSlash(name)) || strings.ContainsAny(name, `/\`) {
return "", errors.New("输出文件名不合法,只能填文件名本身")
}
if !strings.EqualFold(filepath.Ext(name), ".mp4") {
name += ".mp4"
}
return name, nil
}
// 找一个不冲突的输出路径,不默默覆盖上一次的成片
func freeOutPath(dir, base string) string {
cand := filepath.Join(dir, base+".mp4")
if _, err := os.Stat(cand); err != nil {
return cand
}
for i := 2; i < 1000; i++ {
p := filepath.Join(dir, fmt.Sprintf("%s(%d).mp4", base, i))
if _, err := os.Stat(p); err != nil {
return p
}
}
return cand
}
// 解析请求、检查文件并算出合并参数
func buildMergePlan(req mergeRequest) (mergePlan, error) {
var plan mergePlan
// 视频:一行一个,允许夹带空行
var values []string
for _, v := range req.Videos {
for _, p := range strings.FieldsFunc(v, func(r rune) bool { return r == '\n' || r == '\r' }) {
if t := strings.TrimSpace(p); t != "" {
values = append(values, t)
}
}
}
if len(values) == 0 {
return plan, errors.New("请先选择要合并的视频文件")
}
for _, v := range values {
full, err := resolveMergeInput(v)
if err != nil {
return plan, err
}
if _, ok := mergeVideoExts[strings.ToLower(filepath.Ext(full))]; !ok {
return plan, fmt.Errorf("不支持的视频格式 %s,支持 %s", filepath.Ext(full), extListText(mergeVideoExts))
}
if isFileActive(full) {
return plan, fmt.Errorf("%s 正在录制或写入中,等它结束再合并", filepath.Base(full))
}
plan.videos = append(plan.videos, full)
}
if strings.TrimSpace(req.Danmu) == "" {
return plan, errors.New("请先选择弹幕文件")
}
danmuPath, err := resolveMergeInput(req.Danmu)
if err != nil {
return plan, err
}
if _, ok := mergeDanmuExts[strings.ToLower(filepath.Ext(danmuPath))]; !ok {
return plan, fmt.Errorf("不支持的弹幕格式 %s,支持 %s", filepath.Ext(danmuPath), extListText(mergeDanmuExts))
}
if isFileActive(danmuPath) {
return plan, fmt.Errorf("%s 正在录制或写入中,等它结束再合并", filepath.Base(danmuPath))
}
plan.danmu = danmuPath
// 先只解析不落盘,检查阶段不应该产生文件
src, err := prepareDanmuSource(danmuPath, false)
if err != nil {
return plan, err
}
if src.count == 0 {
return plan, errNoDanmu
}
plan.meta = src.meta
// 按录制的开始时间排序,用户填反了也能自动纠正
sort.SliceStable(plan.videos, func(i, j int) bool {
si, sj := nameStartNs(plan.videos[i]), nameStartNs(plan.videos[j])
if si == 0 || sj == 0 {
return plan.videos[i] < plan.videos[j]
}
return si < sj
})
info := mergeInfo{
DanmuName: filepath.Base(danmuPath),
DanmuCount: src.count,
DanmuFirst: src.first,
DanmuLast: src.last,
DanmuHasAvatar: src.hasAvatar,
Source: src.source,
}
ffmpegFile := getFFmpeg()
for _, f := range plan.videos {
fi, err := os.Stat(f)
if err != nil {
return plan, fmt.Errorf("读不到文件信息:%s", filepath.Base(f))
}
d := 0.0
if ffmpegFile != "" {
d = probeDuration(ffmpegFile, f)
}
info.Duration += d
info.Videos = append(info.Videos, mergeVideoInfo{
Name: filepath.Base(f), Size: fi.Size(), Duration: d, InUse: isFileActive(f),
})
}
if ffmpegFile == "" {
return plan, errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
}
info.Offset, info.OffsetNote = guessOffset(plan.videos, danmuPath, src.meta)
if req.Offset != nil {
plan.offset = req.Offset
info.Offset = *req.Offset
info.OffsetNote = "按你填写的偏移"
}
mergeDir := mergeSaveDir()
if strings.TrimSpace(req.OutName) == "" {
plan.out = freeOutPath(mergeDir, danmuBaseName(danmuPath)+"_弹幕合并")
} else {
name, err := sanitizeOutName(req.OutName)
if err != nil {
return plan, err
}
plan.out = filepath.Join(mergeDir, name)
}
info.OutName = filepath.Base(plan.out)
if !src.hasAvatar {
info.Warnings = append(info.Warnings,
"这个弹幕文件里没有头像链接,合成时会按 uid 向 AcFun 反查头像;个别查不到的只烧文字")
}
if info.Duration > 0 && src.last+info.Offset >= info.Duration {
info.Warnings = append(info.Warnings, fmt.Sprintf(
"最后一条弹幕在 %.1f 秒,加上偏移后已超出视频总长 %.1f 秒,超出的弹幕会被丢掉;"+
"如果这个视频只是整场的一段,请把同场的其它分段一起加进来",
src.last+info.Offset, info.Duration))
}
if info.Duration == 0 {
info.Warnings = append(info.Warnings, "读不出视频时长,合成时无法裁掉超出视频的弹幕")
}
plan.info = info
return plan, nil
}
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片
func runManualMerge(plan mergePlan) error {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
}
// 先把任务的输入大小和总时长登记下来,界面在 ffmpeg 跑起来之前就有东西可显示
inputBytes := int64(0)
for _, v := range plan.info.Videos {
inputBytes += v.Size
}
if !jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration) {
return errors.New("已经有别的合并任务在跑,请先等它结束")
}
src, err := prepareDanmuSource(plan.danmu, true)
if err != nil {
return err
}
// 还原结果写不进原目录时用的是系统临时目录里的文件,合并完就删掉
defer dropTempDanmu(src)
// 每一段的录制开始时刻取自文件名,用来算偏移和拼接后的时间轴
segs := make([]recordSegment, 0, len(plan.videos))
for _, f := range plan.videos {
start := nameStartNs(f)
if start == 0 {
start = estimateStartNs(ffmpegFile, f)
}
if start == 0 {
start = time.Now().UnixNano()
}
segs = append(segs, recordSegment{file: f, startNs: start})
}
if len(segs) > 1 {
lPrintf("手动合并:%d 个分段按录制时间顺序拼成一个视频后再合成", len(segs))
}
danmuStartNs := nameStartNs(plan.danmu)
if danmuStartNs == 0 {
danmuStartNs = src.meta.StartTime
}
if danmuStartNs == 0 {
danmuStartNs = segs[0].startNs
}
// 复用自动合并那条链路:多段会先拼接并把弹幕时间重映射到拼接后的时间轴
// (手动合并不删源视频,所以这里用不上「实际参与合并的文件」)
video, jsonl, autoOffset, _, cleanup, err := prepareForMerge(ffmpegFile, segs, src.jsonlPath, danmuStartNs)
defer cleanup()
if err != nil {
return err
}
offset, note := autoOffset, "按文件名里的录制时间自动推算"
if plan.offset != nil {
offset, note = *plan.offset, "按你填写的偏移"
}
if math.Abs(offset) > 60 {
lPrintWarnf("偏移 %.1f 秒偏大,如果弹幕整体对不上画面,调整偏移后重新合并", offset)
}
if err := os.MkdirAll(filepath.Dir(plan.out), 0755); err != nil {
return fmt.Errorf("创建成片文件夹失败:%v", err)
}
if _, err := os.Stat(plan.out); err == nil {
lPrintWarn("成片已存在,将被覆盖:" + plan.out)
}
// 先在本地(弹幕所在的录播目录)合成,完成后再落到成片目录:
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
filepath.Base(video), filepath.Base(jsonl), offset, note)
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
markFileActive(tmpOut, true)
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
markFileActive(tmpOut, false)
if burnErr != nil {
_ = os.Remove(tmpOut)
return burnErr
}
jobSetPhase(jobPhasePlace)
if err := placeOutput(tmpOut, plan.out); err != nil {
_ = os.Remove(tmpOut)
return err
}
lPrintln("手动合并完成:" + plan.out)
// 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案
uid := guessUIDByFileName(plan.out)
if uid == 0 {
uid = guessUIDByFileName(video)
}
text := "手动合并完成\n" + plan.out
if uid > 0 {
text = longID(uid) + " 的录播和弹幕(手动合并)完成\n" + plan.out
}
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text)
return nil
}
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
var req mergeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
plan, err := buildMergePlan(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(plan.info); err != nil {
lPrintErrf("输出合并检查结果失败:%v", err)
}
}
// 开始手动合并:立刻返回,合成在后台进行
func mergeHandler(w http.ResponseWriter, r *http.Request) {
var req mergeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
plan, err := buildMergePlan(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
mergeRun.Lock()
if mergeRun.busy {
busyOut := mergeRun.outName
mergeRun.Unlock()
http.Error(w, "已经有一个手动合并在进行("+busyOut+"),等它结束再说", http.StatusConflict)
return
}
// 任务卡片只有一份:自动合并也可能占着它,那时手动的也得等
if !tryTakeMergeSlot() {
mergeRun.Unlock()
busy := jobBusyName()
if busy == "" {
busy = "另一个合并任务"
}
http.Error(w, "已经有一个合并在进行("+busy+"),等它结束再说", http.StatusConflict)
return
}
mergeRun.busy = true
mergeRun.outName = plan.info.OutName
mergeRun.lastErr = ""
mergeRun.Unlock()
safeGo("手动合并", func() {
defer releaseMergeSlot()
defer func() {
mergeRun.Lock()
mergeRun.busy = false
mergeRun.doneAt = time.Now().Unix()
mergeRun.Unlock()
}()
err := runManualMerge(plan)
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上
jobFinish(jobErrText(err))
if err == nil {
return
}
msg := err.Error()
switch err {
case errNoDanmu:
msg = "弹幕文件里没有弹幕"
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
case errJobStopped:
// 手动停止不是失败,别让界面弹「合并失败」
lPrintWarn("手动合并已被手动停止")
mergeRun.Lock()
mergeRun.lastErr = ""
mergeRun.Unlock()
return
default:
lPrintErrf("手动合并失败:%v", err)
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
}
mergeRun.Lock()
mergeRun.lastErr = msg
mergeRun.Unlock()
})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"outName":` + strconv.Quote(plan.info.OutName) + `}`))
}