Files
acfunlive-remake/manualmerge.go
T
tamakyi 3e16e78003 .ass 时间基准和视频对不上时按第一条弹幕自动对齐,并去掉重复的还原日志
1. 整场弹幕都被判「超出视频时长」,合并直接取消

   上游原版录出来的 .ass 时间不是相对录制起点,而是相对弹幕客户端连上房间的时刻。
   程序先开着、直播中途才开始录的场次,这份 .ass 的零点会比视频起点早一大截
   (用户实测那份早了 7849.92 秒:955 条弹幕、跨度 5436.7 秒,视频只有 5456.9 秒)。
   一律按「视频时间 = 弹幕时间 + 偏移」算的话,这 955 条全都落在 5456.9 秒之外,
   于是「有 955 条弹幕超出了录播视频的时长,已跳过」→「弹幕文件里没有弹幕,取消手动合并」,
   一分半的录播一条弹幕都合不出来。

   这类偏移是能救的:跨度(末 - 首)5436.7 秒装得进 5456.9 秒,整体前移 7849.92 秒之后
   覆盖 0 ~ 5436.7 秒、尾部正好空 20.2 秒 —— 和正常录播的尾部空隙(实测 6~20 秒)吻合,
   是时间基准偏移,不是文件配错。

   新增 fitDanmuToVideo,在探到视频时长之后、裁剪之前介入,触发条件收得很紧:
   只有「按现偏移一条弹幕都用不上」**且**「弹幕跨度 < 视频时长」才整体平移,把第一条
   对齐到视频起点,日志写明平移量、对齐后的覆盖范围和尾部空隙,并提示整体偏早或偏晚
   可以在「偏移」里微调。其余情况完全保持原行为:只是一部分超出 → 照旧只丢尾部;
   跨度本身就装不下 → 一条不留。这样不可能影响任何现在能正常合并的场次。

2. 「已从 xxx.ass 还原出 N 条弹幕」会打印两遍

   页面的「检查」和真正合并会各解析一遍 .ass,而这条日志写在被复用的 parseAssFile 里,
   于是同一句话出现两次,第二遍后面还跟着「已把 xxx.ass 还原成 xxx.jsonl」,看不出谁是谁。
   把这两条日志从 parseAssFile 里摘出去(它改成返回坏行数 bad,由调用方决定要不要告警),
   交给真正落盘那次的调用方 prepareDanmuSource(path, true) 打印,现在还会写明写成了
   哪个文件;坏行警告统一走新增的 logAssBadLines,不再重复。

   顺手给「最后一条弹幕超出视频总长」那条警告补了指引,说明合成时会自动对齐。

3. 测试里后台任务漏到用例结束之后

   safeGo 原来直接把 goroutine 扔出去,用例结束它还在读全局配置,下一个用例开头一改配置,
   -race 就判成数据竞争(而且报在后面的用例上,很难看出真凶)。把「怎么起后台任务」抽成
   var runBackground,生产行为不变,测试里换成可等待的版本,清理时等它收工。

   注意:这个竞争单独 -run 一个用例复现不出来(-race -count=3 也过),必须整包跑
   go test -race . 才会在后续用例上报 race detected。

验证

- gofmt 无输出 / go vet 通过 / go test(182 个,18.5s)全过 / go test -race 整包跑 20 轮
  (13 轮顺序 + 6 轮 -shuffle=on)全过 / staticcheck 无输出 / GOOS=linux go build 通过。
  另:有一轮 -race 曾报过 FAIL、随后 20 轮都没能复现,怀疑是 TestDownloadAvatarCapsResponseBody
  自带的抖动(它的断言依赖「服务端写不完 10MB」,受内核缓冲大小影响),与本批改动无关,
  该文件本批也没动过;待单独确认。
- 反向验证从 32 项扩到 37 项全过(新增:自动对齐 / 只丢尾部不平移 / 跨度装不进不强平移 /
  还原日志只打一次 / 后台任务可等待);脚本新增单用例附加参数 "args" 与不带 -run 的整包用例。
- 用用户那份真实素材(955 条,7849.92 ~ 13286.59 秒,视频 5456.883 秒)端到端跑通:
  不填偏移 + 5460 秒视频 → 日志「弹幕时间基准和视频对不上:955 条弹幕全部落在视频时长
  (5460.0 秒)之外,最早那条在 7849.9 秒;已按「第一条弹幕对齐视频起点」整体前移
  7849.9 秒(偏移 0.0 → -7849.9)」「对齐后弹幕覆盖 0.0 ~ 5436.7 秒,视频总长 5460.0 秒
  (尾部空 23.3 秒)」,分 5 块渲染,exit=0,成片 5462.31 秒 / 23.3 MB。
  再按 jsonl + 偏移反推「哪些时刻该有弹幕」,随机取 40 个应有时刻 + 20 个任意弹幕窗口都
  不覆盖的空屏时刻,抽帧数白像素(白字黑底):60/60 全对,跨块边界(1116/2435/3393/4408)
  与最尾部 t=5435.7 都对得上。
- 60 秒小片新旧对照(真实 9 条弹幕、原始时间戳 7849.92 ~ 7908.7):旧版「有 9 条弹幕超出了
  录播视频的时长(60.1 秒),已跳过」+「jsonl 里没有弹幕」、无成片;新版自动前移 7849.9 秒、
  9 条全画上、成片 34 MB。抽帧看轨迹仍是匀速 10 秒穿屏(danmuShowSeconds),
  头像跟着文字同步走,没有回归。
2026-09-26 17:51:43 +08:00

687 lines
23 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, bad, 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
}
// 还原日志只在真正落盘这次打:页面的「检查」也会解析一遍同一个 .ass,
// 两处都打日志的话同一次还原会在日志里出现两遍
summary := fmt.Sprintf("已从 %s 还原出 %d 条弹幕(%dx%d,字号 %d,弹幕到 %s)",
filepath.Base(path), len(records), meta.PlayResX, meta.PlayResY, meta.FontSize,
formatSeconds(records[len(records)-1].Time))
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
lPrintf("%s,写成 %s", summary, filepath.Base(tmp))
logAssBadLines(path, bad)
return src, nil
}
lPrintf("%s,写成 %s", summary, filepath.Base(jsonlPath))
logAssBadLines(path, bad)
return src, nil
}
// .ass 里有行没能解析出来时提醒一次(只在真正落盘那次调,避免重复)
func logAssBadLines(path string, bad int) {
if bad > 0 {
lPrintWarnf("%s 里有 %d 行弹幕没能解析出来,已跳过", filepath.Base(path), bad)
}
}
// .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) + `}`))
}