一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。
二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
- POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
- 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
- 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
失败或中断删临时文件,不留半成品
- 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止
三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。
四、审阅中一并修掉 / 加固的点
- 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
- 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
- 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
- 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
- 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
- probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
- 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则
验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
跳过无视频轨文件 / 估算失败提示 / 结果徽标)
820 lines
28 KiB
Go
820 lines
28 KiB
Go
// 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": {}}
|
||
|
||
// 手动合并的请求体
|
||
//
|
||
// 两种写法:只填 videos/danmu 是单个任务(老样子);填 jobs 是一次排多个任务,
|
||
// 每个任务自带视频、弹幕和合成参数,服务端按顺序串行合并
|
||
type mergeRequest struct {
|
||
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
|
||
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
|
||
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
|
||
OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名
|
||
|
||
Options mergeOptions `json:"options"` // 这一次合成的参数,不填的项用全局设置
|
||
Jobs []mergeJobReq `json:"jobs"` // 批量:每个任务一套自己的文件和参数
|
||
}
|
||
|
||
// 批量里的一个合并任务
|
||
type mergeJobReq struct {
|
||
Videos []string `json:"videos"` // 这一组的视频,多个就是同一场的断流分段,会先拼起来
|
||
Danmu string `json:"danmu"` // 这一组的弹幕文件
|
||
Offset *float64 `json:"offset"` // 这一组的偏移
|
||
OutName string `json:"outName"` // 这一组的输出文件名
|
||
Options mergeOptions `json:"options"` // 这一组的合成参数
|
||
}
|
||
|
||
// 一次最多排多少个合并任务。再多就得等太久,队列状态也会一直占着
|
||
const maxMergeJobs = 50
|
||
|
||
// 批量任务里每个任务的结果,页面按它显示「第几个、成功还是失败」
|
||
type mergeJobResult struct {
|
||
OutName string `json:"outName"`
|
||
Err string `json:"err"` // 空表示成功
|
||
}
|
||
|
||
func (r mergeJobResult) ok() bool { return r.Err == "" }
|
||
|
||
// 检查结果里每个视频的信息
|
||
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
|
||
opts mergeOptions // 这一组自己的合成参数
|
||
info mergeInfo
|
||
}
|
||
|
||
// 手动合并的运行状态,页面轮询它来决定按钮是否可用
|
||
var mergeRun struct {
|
||
sync.Mutex
|
||
busy bool
|
||
outName string
|
||
lastErr string
|
||
doneAt int64
|
||
// 批量合并的进度:一共几个、已经做完几个、每个的结果
|
||
total int
|
||
done int
|
||
results []mergeJobResult
|
||
}
|
||
|
||
// 手动合并是否正在进行
|
||
func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) {
|
||
mergeRun.Lock()
|
||
defer mergeRun.Unlock()
|
||
return mergeRun.busy, mergeRun.outName, mergeRun.lastErr, mergeRun.doneAt
|
||
}
|
||
|
||
// 批量合并的队列进度,页面用它显示「第 2/3 个」和各任务的结果
|
||
func mergeQueueSnapshot() (busy bool, done, total int, results []mergeJobResult) {
|
||
mergeRun.Lock()
|
||
defer mergeRun.Unlock()
|
||
out := make([]mergeJobResult, len(mergeRun.results))
|
||
copy(out, mergeRun.results)
|
||
return mergeRun.busy, mergeRun.done, mergeRun.total, out
|
||
}
|
||
|
||
// 解析用户填的路径:可以先当文件名在录播/成片文件夹里找,也可以填绝对或相对路径
|
||
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
|
||
}
|
||
|
||
// 把一次请求拆成若干个合并任务并逐个检查。全部检查通过才返回:
|
||
// 排到第 3 个才发现文件不对、前两个已经合完了,这种情况很难跟用户解释
|
||
func buildMergePlans(req mergeRequest) ([]mergePlan, error) {
|
||
if len(req.Jobs) == 0 {
|
||
plan, err := buildMergePlan(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return []mergePlan{plan}, nil
|
||
}
|
||
if len(req.Jobs) > maxMergeJobs {
|
||
return nil, fmt.Errorf("一次最多排 %d 个合并任务,这次有 %d 个", maxMergeJobs, len(req.Jobs))
|
||
}
|
||
plans := make([]mergePlan, 0, len(req.Jobs))
|
||
for i, job := range req.Jobs {
|
||
plan, err := buildMergePlan(mergeRequest{
|
||
Videos: job.Videos,
|
||
Danmu: job.Danmu,
|
||
Offset: job.Offset,
|
||
OutName: job.OutName,
|
||
Options: job.Options,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("第 %d 个任务:%v", i+1, err)
|
||
}
|
||
plans = append(plans, plan)
|
||
}
|
||
return plans, nil
|
||
}
|
||
|
||
// 解析请求、检查文件并算出合并参数
|
||
func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
||
var plan mergePlan
|
||
ffmpegFile := getFFmpeg()
|
||
plan.opts = req.Options
|
||
if msg := req.Options.validate(ffmpegFile); msg != "" {
|
||
return plan, errors.New(msg)
|
||
}
|
||
|
||
// 视频:一行一个,允许夹带空行
|
||
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,
|
||
}
|
||
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 的任务卡片。
|
||
// idx / total 是批量队列里的位置(单任务传 1, 1),只用来在日志和界面上标「第几个」
|
||
func runManualMerge(plan mergePlan, idx, total int) 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(jobKindManualMerge, plan.info.OutName, inputBytes, plan.info.Duration) {
|
||
return errJobNotStarted
|
||
}
|
||
if idx > 0 && total > 0 {
|
||
jobQueue(idx, total)
|
||
}
|
||
|
||
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))
|
||
|
||
// 批量合并时在日志里标出这是第几个,翻日志的时候对得上号
|
||
tag := ""
|
||
if total > 1 {
|
||
tag = fmt.Sprintf("(第 %d/%d 个)", idx, total)
|
||
}
|
||
lPrintf("手动合并开始%s:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
|
||
tag, 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, plan.opts)
|
||
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("手动合并完成" + tag + ":" + 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
|
||
}
|
||
if total > 1 {
|
||
text += fmt.Sprintf("\n(第 %d/%d 个)", idx, total)
|
||
}
|
||
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text)
|
||
return nil
|
||
}
|
||
|
||
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成。
|
||
// 请求里带 jobs 时一次检查多个任务,返回 {jobs:[...]};单个任务仍直接返回 info
|
||
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
|
||
}
|
||
plans, err := buildMergePlans(req)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
if len(req.Jobs) == 0 {
|
||
if err := json.NewEncoder(w).Encode(plans[0].info); err != nil {
|
||
lPrintErrf("输出合并检查结果失败:%v", err)
|
||
}
|
||
return
|
||
}
|
||
infos := make([]mergeInfo, 0, len(plans))
|
||
for _, p := range plans {
|
||
infos = append(infos, p.info)
|
||
}
|
||
if err := json.NewEncoder(w).Encode(map[string]any{"jobs": infos}); 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
|
||
}
|
||
plans, err := buildMergePlans(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 = plans[0].info.OutName
|
||
mergeRun.lastErr = ""
|
||
mergeRun.total = len(plans)
|
||
mergeRun.done = 0
|
||
mergeRun.results = nil
|
||
mergeRun.Unlock()
|
||
|
||
total := len(plans)
|
||
safeGo("手动合并", func() {
|
||
defer releaseMergeSlot()
|
||
defer func() {
|
||
mergeRun.Lock()
|
||
mergeRun.busy = false
|
||
mergeRun.doneAt = time.Now().Unix()
|
||
mergeRun.Unlock()
|
||
}()
|
||
stopped := false
|
||
for i, plan := range plans {
|
||
mergeRun.Lock()
|
||
mergeRun.outName = plan.info.OutName
|
||
mergeRun.done = i
|
||
mergeRun.Unlock()
|
||
|
||
err := runManualMerge(plan, i+1, total)
|
||
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上。
|
||
// 只有真的起了任务才收尾:没起成说明占着卡片的是别的任务,别把人家的进度清掉
|
||
if err != errJobNotStarted {
|
||
jobFinish(jobErrText(err))
|
||
}
|
||
if err == nil {
|
||
mergeRun.Lock()
|
||
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName})
|
||
mergeRun.Unlock()
|
||
continue
|
||
}
|
||
msg := err.Error()
|
||
switch err {
|
||
case errNoDanmu:
|
||
msg = "弹幕文件里没有弹幕"
|
||
lPrintWarn("弹幕文件里没有弹幕,跳过这个任务")
|
||
case errJobStopped:
|
||
// 手动停止不是失败,别让界面弹「合并失败」;排在后面的任务也不再接着做
|
||
lPrintWarn("手动合并已被手动停止,剩下的任务不再继续")
|
||
stopped = true
|
||
default:
|
||
lPrintErrf("手动合并失败:%v", err)
|
||
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
|
||
}
|
||
mergeRun.Lock()
|
||
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName, Err: msg})
|
||
mergeRun.lastErr = msg
|
||
mergeRun.Unlock()
|
||
if stopped {
|
||
break
|
||
}
|
||
}
|
||
mergeRun.Lock()
|
||
mergeRun.done = len(mergeRun.results)
|
||
mergeRun.Unlock()
|
||
if total > 1 {
|
||
ok, failed := 0, 0
|
||
mergeRun.Lock()
|
||
for _, r := range mergeRun.results {
|
||
if r.ok() {
|
||
ok++
|
||
} else {
|
||
failed++
|
||
}
|
||
}
|
||
mergeRun.Unlock()
|
||
lPrintf("手动合并队列结束:共 %d 个,成功 %d 个,失败 %d 个", total, ok, failed)
|
||
}
|
||
})
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"count":` + strconv.Itoa(total) +
|
||
`,"outName":` + strconv.Quote(plans[0].info.OutName) + `}`))
|
||
}
|