Files
acfunlive-remake/manualmerge.go
T
tamakyi e35a4f7cef 合并先在本地做完再落到成片目录;头像支持 gif 和静态 webp
起因是核查合并落盘和头像处理时发现的两处问题:

1) 成片落盘:ffmpeg 原来是直接写目标目录(automerge.go 里那句注释「不再事后再
   移动一次」)。成片目录可能是网络盘或外接盘,写到一半失败会在那里留下一个
   看起来正常的半成品。现在改成先本地合成、完成后再落盘:
   - 输出先写到弹幕文件所在目录(本地)的 acfunlive_chunk_merging_*
   - 写完后由 placeOutput 落盘:同盘直接 os.Rename(瞬时),跨盘退回拷贝;
     拷贝中途失败留下的是 *.acfunlive.part,一眼看得出不是成品
   - 成功或失败都会清掉本地临时文件,失败时两个目录都不留东西
   - 临时文件沿用 chunkTempPrefix,不再出现在文件管理的列表里(listFiles 过滤)
   - 启动时清理上次残留;删不掉时提示一句 —— 强杀程序不会连带杀掉 ffmpeg
     子进程,它可能还在写这个文件,这种情况下次启动会再清一次

2) 头像:downloadAvatar 只注册了 jpeg 和 png 解码器,AcFun 上的 gif 头像必然
   解码失败,静态 webp 同样。加上 _ "image/gif"(多帧取第一帧)和
   _ "golang.org/x/image/webp"(x/image 本来就是直接依赖,零新增模块)。
   动画 webp 两条路都解不开(x/image 报 invalid format,ffmpeg 5.0 也不行),
   保持只烧文字。解码失败时错误信息带上 Content-Type,方便排查是哪张图。

在隔离实例上用真 ffmpeg 跑了真合并验证:gif 头像和 webp 头像都进了缓存并烧进
画面(抽帧确认圆形头像);日志里 ffmpeg 的输出是 record 下的临时文件,完成后
才出现在 merged;坏视频合成失败后成片目录没有半成品、录播目录零残留;合并
中途强杀留下的临时文件在下次启动时被清掉。

新增 output_test.go(7 个)和 avatar_test.go(3 个);go test ./...、gofmt、
go vet 干净。
2026-09-25 17:34:31 +08:00

565 lines
18 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)
}
// 扩展名白名单的说明文字
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 // 来源说明
}
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
return src, nil
}
lPrintf("已把 %s 还原成 %s", filepath.Base(path), filepath.Base(jsonlPath))
return src, nil
}
// 输出文件名只能是文件名本身,且固定为 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
}
// 真正执行合并,进度直接打到运行日志里
func runManualMerge(plan mergePlan) error {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
}
src, err := prepareDanmuSource(plan.danmu, true)
if err != nil {
return err
}
// 每一段的录制开始时刻取自文件名,用来算偏移和拼接后的时间轴
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
}
if err := placeOutput(tmpOut, plan.out); err != nil {
_ = os.Remove(tmpOut)
return err
}
lPrintln("手动合并完成:" + plan.out)
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
}
mergeRun.busy = true
mergeRun.outName = plan.info.OutName
mergeRun.lastErr = ""
mergeRun.Unlock()
safeGo("手动合并", func() {
defer func() {
mergeRun.Lock()
mergeRun.busy = false
mergeRun.doneAt = time.Now().Unix()
mergeRun.Unlock()
}()
if err := runManualMerge(plan); err != nil {
msg := err.Error()
if err == errNoDanmu {
msg = "弹幕文件里没有弹幕"
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
} else {
lPrintErrf("手动合并失败:%v", err)
}
mergeRun.Lock()
mergeRun.lastErr = msg
mergeRun.Unlock()
}
})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"outName":` + strconv.Quote(plan.info.OutName) + `}`))
}