diff --git a/README.md b/README.md index 2c88e0d..6c67601 100644 --- a/README.md +++ b/README.md @@ -162,22 +162,23 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、选择录制的码率档位、修改保存目录和自动合并设置。 -页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区: +页面按用途分成七个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区: | 分区 | 内容 | | --- | --- | | 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 | | 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 | -| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 | +| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并(可一次排多个任务,每个任务单独配参数) | +| 转码 | 把录播或成片重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 | | 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 | | 文件 | 磁盘剩余容量、录播和成片的文件列表 | | 设置 | 是否允许外网访问、管理口令的查看与更换 | ### 转换任务进度 -自动合并和手动合并跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是: +自动合并、手动合并和转码跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是: -- **阶段步骤条**:准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,走过的打勾、当前的高亮(暂停时变红); +- **阶段步骤条**:合并是准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,转码是准备 → 转码 → 落盘,走过的打勾、当前的高亮(暂停时变红);一次排了多个任务时标题上还会标`第 n/N 个`; - **总进度条和百分比**:渲染阶段按「已渲染时长 ÷ 视频总时长」算,其余阶段按各自的完成比例折算后加权; - **实时指标**:渲染倍速、帧率、已处理时长 / 总时长、已用时间、已写出多少; - **预计剩余时间**:渲染阶段用「剩余时长 ÷ 当前倍速」,别的阶段按已用时间线性外推; @@ -253,15 +254,40 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe ### 手动合并 -web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。 +web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。**可以一次排多个任务,每个任务一套自己的文件和参数。** -- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。 -- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上并提示一句;找不到、或者弹幕框里已经有内容时就保持原样,自己手选即可。 +选好视频后会按文件名自动归组:文件名是`2026-09-25 01-00-00 主播名 标题.mp4`这种格式,去掉开头的时间和扩展名后剩下的(主播名 + 标题)相同、而且相邻两段录制时间差在 2 小时以内的算同一场——同一场断流分成的几段归到一个任务里、先拼起来再合,不同场次各排一个任务、依次合出多个成片。归错了(比如同一天连着两场、标题又一样但间隔在 2 小时内)可以点任务行上的`拆成单独任务`把它们拆开。 + +每个任务行里能单独改: + +- **视频**:行里列出这一组的文件,可以整组移除或拆成一人一个任务。既可以从录播目录的下拉列表里多选,也可以点`浏览`从别的文件夹挑。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用。 +- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl)。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上;找不到就保持空着,用行里的`…`按钮手选。 - **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。 -- **输出文件名**:可以改,重名时自动加`(2)`之类的后缀,不会覆盖已有文件。 -- **先预览再合并**:`预览`按钮会先把分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件列出来,确认没问题再点`开始合并`。 +- **输出文件名**:留空时按弹幕文件名加`_弹幕合并`自动命名,重名会自动加`(2)`之类的后缀;自己填了名字就按填的来(**与已有文件同名时会被覆盖**,注意别填错)。 +- **本次的合成参数**:码率(Kbps)、输出帧率、弹幕字号(像素)、编码器。**留空就沿用`成片输出`里的全局设置**,填了就只对这一个任务生效——比如某一场要压小一点、另一场保持原码率。 +- **先预览再合并**:`检查`按钮会把每个任务的分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件逐段列出来,确认没问题再点`开始合并`。 -一次只允许跑一个手动合并任务,重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。 +一次只跑一个合并任务,排着的按列表顺序来;重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。同一组里的多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。 + +### 转码 + +`转码`分区用来把录播或成片重新压一遍:体积太大想压小、想换成 HEVC、想降分辨率或帧率、想换音轨,都在这里做。**转码不动原文件**,成片另存到输出目录(留空就是成片文件夹)。 + +从文件列表里多选加入之后,每个文件一行,参数各自独立: + +| 参数 | 说明 | +| --- | --- | +| 输出文件名 | 默认是`原名_转码.mp4`;这个默认名重名时会自动加`(2)`之类的后缀,自己改成别的名字就按填的来(**与已有文件同名时会被覆盖**) | +| 编码器 | `自动`按本机可用性挑(优先 Intel QSV 的 HEVC),也可以指定 HEVC 硬件 / H.264 硬件 / H.264 软件;本机没有的编码器会被挡住 | +| 码率控制 | `源片码率的比例`(默认 60%)/ `指定目标码率` / `质量模式`(CRF 或 ICQ,数值越小画质越好)/ `跟随源片码率` | +| 编码速度 | veryfast ~ slower,慢一点压得小一点 | +| 输出帧率 | 0 表示跟随源片;转码场景下可以按填的值升也可以降 | +| 分辨率 | 按高度缩放,只列出比源片低的档,不会把小图放大 | +| 音频 | 直接拷贝 / 重编码 128k 或 192k / 去掉音轨 | + +每行下面实时显示**预估产物大小**和相对源片的百分比(体积是服务端按「码率 × 时长」算的,目标码率模式是准数,质量模式只给粗估并标注依据),列表底部还有一整批的合计。调好第一个之后点`把第一个的参数套到全部`可以一次性铺给其它行。 + +转码和合并共用同一张进度卡片、同一个任务位,所以一次只跑一个文件,排着的按列表顺序来,也能在`监控`页暂停和停止;停止只丢当前这个,已经在输出目录里落地的成品不受影响。 ### 通知推送 diff --git a/automerge.go b/automerge.go index 496a340..bb4e46b 100644 --- a/automerge.go +++ b/automerge.go @@ -321,7 +321,7 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs inputBytes += fileSizeOrZero(p) } } - if !jobStart("自动合并", filepath.Base(outFile), inputBytes, 0) { + if !jobStart(jobKindAutoMerge, filepath.Base(outFile), inputBytes, 0) { // 走到这里说明没先拿 mergeSlot,与其把别人的进度冲掉不如直接放弃这一场 return errors.New("开始任务失败:还有别的合并任务没结束,本场合并已跳过") } @@ -335,7 +335,8 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs // 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉 markFileActive(tmpOut, true) - burnErr := burnDanmu(jsonl, video, tmpOut, offset) + // 自动合并没有单独的参数,全部沿用全局设置 + burnErr := burnDanmu(jsonl, video, tmpOut, offset, mergeOptions{}) markFileActive(tmpOut, false) if burnErr != nil { _ = os.Remove(tmpOut) diff --git a/danmuburn.go b/danmuburn.go index 6023b22..c4e4ed4 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -20,7 +20,6 @@ import ( "os" "os/exec" "path/filepath" - "regexp" "sort" "strconv" "strings" @@ -147,6 +146,70 @@ type burnContext struct { // 透明头像层(无源片模式)默认的帧率 const defaultLayerFps = 30 +// 一次合成的参数。手动合并里每个任务可以自带一套,不带的项回落到全局设置。 +// +// 码率、帧率、字号都用指针:0 本身是有明确含义的(自动码率 / 跟随源片 / 自动字号), +// 用普通 int 分不清「没填」和「填了 0」,手动合并就会把用户在全局设置里填的值顶掉 +type mergeOptions struct { + Bitrate *int `json:"bitrate,omitempty"` // 合成码率(Kbps),0 = 按「源片码率 + 弹幕余量」自动 + Fps *int `json:"fps,omitempty"` // 输出帧率,0 = 跟随源片 + FontSize *int `json:"fontSize,omitempty"` // 弹幕字号(像素),0 = 按画面分辨率自动 + Encoder string `json:"encoder,omitempty"` // 指定 ffmpeg 编码器,空或 auto = 自动 +} + +// 取本次合成用的码率 / 帧率 / 字号,没指定就沿用全局设置 +func (o mergeOptions) bitrate() int { + if o.Bitrate != nil { + return *o.Bitrate + } + return config.MergeBitrate +} + +func (o mergeOptions) fps() int { + if o.Fps != nil { + return *o.Fps + } + return config.MergeFps +} + +func (o mergeOptions) fontSize() int { + if o.FontSize != nil { + return *o.FontSize + } + return config.DanmuFontSize +} + +// 指定的编码器(空表示让 ffmpeg 那边按可用性自动挑) +func (o mergeOptions) encoder() string { + if o.Encoder == "" || o.Encoder == "auto" { + return "" + } + return o.Encoder +} + +// 检查一次合成的参数,返回给用户的错误说明(空字符串表示通过) +func (o mergeOptions) validate(ffmpegFile string) string { + if o.Bitrate != nil && (*o.Bitrate < 0 || *o.Bitrate > maxBitrateKbps) { + return fmt.Sprintf("合成码率要在 0(自动)到 %d(Kbps)之间", maxBitrateKbps) + } + if o.Fps != nil && (*o.Fps < 0 || *o.Fps > maxMergeFps) { + return fmt.Sprintf("输出帧率只能在 0(跟随源片)到 %d 之间", maxMergeFps) + } + if o.FontSize != nil && *o.FontSize < 0 { + return "弹幕字号不能小于 0(0 表示按画面分辨率自动)" + } + if enc := o.encoder(); enc != "" { + for _, n := range availableEncoders(ffmpegFile) { + if n == enc { + return "" + } + } + return fmt.Sprintf("本机没有可用的编码器 %s,可用的是:%s", + enc, strings.Join(availableEncoders(ffmpegFile), "、")) + } + return "" +} + // 设置里的输出帧率 vs 源片帧率:只在目标**更高**时才加 fps 滤镜 —— 降帧只会更卡。 // 源片帧率探测不到(0)时也不动,免得把高帧率源压到某个值 func outputFps(target int, source float64) int { @@ -275,7 +338,7 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) { } // 还原结果写不进原目录时会落在系统临时目录里,收工前删掉。 // 这里到处都是 os.Exit,defer 不会被执行,所以显式调用 - if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset); err != nil { + if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset, mergeOptions{}); err != nil { dropTempDanmu(src) if err == errNoDanmu { lPrintWarn(err.Error()) @@ -346,8 +409,9 @@ func fitDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]da } // 合成一个 jsonl(和可选的视频)为带头像的弹幕层 -// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频 -func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { +// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频。 +// opt 是这一次的合成参数,不填的项用全局设置(见 mergeOptions) +func burnDanmu(jsonlFile, videoFile, outFile string, offset float64, opt mergeOptions) error { jsonlFile, err := filepath.Abs(jsonlFile) if err != nil { return err @@ -420,11 +484,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { return errors.New("找不到 ffmpeg,请先安装并加入 PATH,或把 ffmpeg 放在本程序所在文件夹,或在设置里填写 ffmpeg 路径") } + // 时长、分辨率、码率、帧率一次探测拿全(原来为了拿时长和视频流各起了一次 ffmpeg) + var vinfo mediaInfo + if burnMode { + vinfo = probeMedia(ffmpegFile, videoFile) + } + // 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉; // 时间基准整体偏移的 .ass(见 fitDanmuToVideo)在这一步救回来 videoDuration := 0.0 if burnMode { - videoDuration = probeDuration(ffmpegFile, videoFile) + videoDuration = vinfo.duration if videoDuration > 0 { records, offset = fitDanmuToVideo(records, offset, videoDuration) if len(records) == 0 { @@ -434,17 +504,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { } // 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace); - // 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率) + // 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)。 + // 码率沿用「视频流自己的,没有就用容器总码率」的老口径(见 streamOrContainerKbps) sourceKbps := 0 sourceFps := 0.0 if burnMode { - info := probeVideoStream(ffmpegFile, videoFile) - sourceKbps, sourceFps = info.kbps, info.fps - unifyDanmuSpace(&meta, info.width, info.height) + sourceKbps, sourceFps = streamOrContainerKbps(vinfo), vinfo.fps + unifyDanmuSpace(&meta, vinfo.width, vinfo.height) lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → ")) } // 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布 - applyFontSize(&meta, config.DanmuFontSize) + applyFontSize(&meta, opt.fontSize()) // 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾 relayoutLanes(records, meta) @@ -476,7 +546,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { } // 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式 - bitrate := config.MergeBitrate + burnBitrate + bitrate := opt.bitrate() + burnBitrate if bitrate == 0 && burnMode { if auto := autoMergeBitrate(sourceKbps); auto > 0 { lPrintf("合成码率填的 0,按源片 %d kbps + 弹幕余量 %d kbps 取 %d kbps", sourceKbps, danmuBitrateMargin, auto) @@ -486,11 +556,12 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { } } - // 输出帧率:设置填 0 = 跟随源片(不动)。低帧率录播(15/24fps)里弹幕是一格一格跳的, + // 输出帧率:填 0 = 跟随源片(不动)。低帧率录播(15/24fps)里弹幕是一格一格跳的, // 提到更高帧率之后弹幕层按新帧率重绘,字和头像都顺了;源片本来就够高就不动 - outFps := outputFps(config.MergeFps, sourceFps) - if burnMode && config.MergeFps > 0 && outFps == 0 { - lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, config.MergeFps) + wantFps := opt.fps() + outFps := outputFps(wantFps, sourceFps) + if burnMode && wantFps > 0 && outFps == 0 { + lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, wantFps) } if outFps > 0 { lPrintf("输出帧率:源片 %.2f fps,按设置提到 %d fps(弹幕层会按新帧率重绘)", sourceFps, outFps) @@ -515,7 +586,8 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { bitrate: bitrate, dir: workDir, outFps: outFps, - layerFps: layerFps(config.MergeFps), + layerFps: layerFps(wantFps), + encoder: opt.encoder(), } chunks := splitDanmuChunks(records, offset, videoDuration) @@ -724,51 +796,14 @@ type videoStreamInfo struct { fps float64 // 帧率 } -// 探测视频流的分辨率、码率和帧率 +// 探测视频流的分辨率、码率和帧率。 +// 实际解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里只挑视频那几个字段 func probeVideoStream(ffmpegFile, path string) videoStreamInfo { - cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path) - var buf bytes.Buffer - cmd.Stderr = &buf - // 没指定输出文件时 ffmpeg 会返回非 0,但流信息已经打印出来了 - _ = cmd.Run() - - var info videoStreamInfo - for _, line := range strings.Split(buf.String(), "\n") { - // 容器的总码率(含音频)先记下来,视频流自己没写码率时用它 - if strings.Contains(line, "Duration:") { - if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 { - info.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 { - info.width, _ = strconv.Atoi(m[1]) - info.height, _ = strconv.Atoi(m[2]) - } - if m := videoBitrateRe.FindStringSubmatch(line); len(m) == 2 { - info.kbps, _ = strconv.Atoi(m[1]) - } - if m := videoFpsRe.FindStringSubmatch(line); len(m) == 2 { - info.fps, _ = strconv.ParseFloat(m[1], 64) - } - } - return info + m := probeMedia(ffmpegFile, path) + // 口径跟以前一致:视频流自己没写码率就退回容器总码率,别改成扣掉音频的版本 + return videoStreamInfo{width: m.width, height: m.height, kbps: streamOrContainerKbps(m), fps: m.fps} } -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`) - // 帧率,形如「1920x1080, 30 fps, 30 tbr, 1k tbn」 - videoFpsRe = regexp.MustCompile(`, (\d+(?:\.\d+)?) fps`) -) - // 合成码率填 0(默认)时的算法:源片码率 + 弹幕余量。 // 烧进去的文字和头像都是高频细节,跟源片同样码率会糊,所以留一点余量; // 探测不到源片码率时返回 0,让调用方退回质量模式。 @@ -790,26 +825,12 @@ const ( minMergeBitrate = 800 ) -// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0 +// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0。 +// 解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里保留薄封装给老调用点 func probeDuration(ffmpegFile, path string) float64 { - cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path) - var buf bytes.Buffer - cmd.Stderr = &buf - // 没指定输出文件时 ffmpeg 会返回非 0,但时长信息已经打印出来了 - _ = cmd.Run() - - m := durationRe.FindStringSubmatch(buf.String()) - if len(m) != 4 { - return 0 - } - h, _ := strconv.Atoi(m[1]) - mi, _ := strconv.Atoi(m[2]) - s, _ := strconv.ParseFloat(m[3], 64) - return float64(h)*3600 + float64(mi)*60 + s + return probeMedia(ffmpegFile, path).duration } -var durationRe = regexp.MustCompile(`Duration: (\d+):(\d+):(\d+\.\d+)`) - // 弹幕不多时直接一次渲染完,同样从 jsonl 生成字幕,不依赖录制时留下的 .nick.ass func (c *burnContext) burnSingle(records []danmuRecord) error { chunk := danmuChunk{start: 0, end: math.Inf(1), records: records} diff --git a/manualmerge.go b/manualmerge.go index 54cd85e..d76ecb7 100644 --- a/manualmerge.go +++ b/manualmerge.go @@ -27,13 +27,39 @@ var mergeVideoExts = map[string]struct{}{ 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"` @@ -65,6 +91,7 @@ type mergePlan struct { out string // 成片的绝对路径 meta danmuMeta offset *float64 + opts mergeOptions // 这一组自己的合成参数 info mergeInfo } @@ -75,6 +102,10 @@ var mergeRun struct { outName string lastErr string doneAt int64 + // 批量合并的进度:一共几个、已经做完几个、每个的结果 + total int + done int + results []mergeJobResult } // 手动合并是否正在进行 @@ -84,6 +115,15 @@ func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) { 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) @@ -359,9 +399,44 @@ func freeOutPath(dir, base string) string { 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 @@ -431,7 +506,6 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) { DanmuHasAvatar: src.hasAvatar, Source: src.source, } - ffmpegFile := getFFmpeg() for _, f := range plan.videos { fi, err := os.Stat(f) if err != nil { @@ -489,8 +563,9 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) { return plan, nil } -// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片 -func runManualMerge(plan mergePlan) error { +// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片。 +// idx / total 是批量队列里的位置(单任务传 1, 1),只用来在日志和界面上标「第几个」 +func runManualMerge(plan mergePlan, idx, total int) error { ffmpegFile := getFFmpeg() if ffmpegFile == "" { return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径") @@ -501,8 +576,11 @@ func runManualMerge(plan mergePlan) error { for _, v := range plan.info.Videos { inputBytes += v.Size } - if !jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration) { - return errors.New("已经有别的合并任务在跑,请先等它结束") + 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) @@ -561,13 +639,18 @@ func runManualMerge(plan mergePlan) error { // 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品 tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out)) - lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)", - filepath.Base(video), filepath.Base(jsonl), offset, note) + // 批量合并时在日志里标出这是第几个,翻日志的时候对得上号 + 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) + burnErr := burnDanmu(jsonl, video, tmpOut, offset, plan.opts) markFileActive(tmpOut, false) if burnErr != nil { _ = os.Remove(tmpOut) @@ -579,7 +662,7 @@ func runManualMerge(plan mergePlan) error { return err } - lPrintln("手动合并完成:" + plan.out) + lPrintln("手动合并完成" + tag + ":" + plan.out) // 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案 uid := guessUIDByFileName(plan.out) if uid == 0 { @@ -589,24 +672,38 @@ func runManualMerge(plan mergePlan) error { 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 } - plan, err := buildMergePlan(req) + plans, err := buildMergePlans(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 { + 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) } } @@ -618,7 +715,7 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) return } - plan, err := buildMergePlan(req) + plans, err := buildMergePlans(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -642,10 +739,14 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) { return } mergeRun.busy = true - mergeRun.outName = plan.info.OutName + 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() { @@ -654,33 +755,65 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) { 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("手动合并已被手动停止") + stopped := false + for i, plan := range plans { mergeRun.Lock() - mergeRun.lastErr = "" + mergeRun.outName = plan.info.OutName + mergeRun.done = i mergeRun.Unlock() - return - default: - lPrintErrf("手动合并失败:%v", err) - notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err) + + 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.lastErr = msg + 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(`{"outName":` + strconv.Quote(plan.info.OutName) + `}`)) + _, _ = w.Write([]byte(`{"count":` + strconv.Itoa(total) + + `,"outName":` + strconv.Quote(plans[0].info.OutName) + `}`)) } diff --git a/manualmerge_test.go b/manualmerge_test.go index 378092c..5eb4c0f 100644 --- a/manualmerge_test.go +++ b/manualmerge_test.go @@ -68,7 +68,7 @@ Dialogue: 0,0:00:03.50,0:00:13.50,Danmu,丙(303),20,20,2,,{\move(400,24,-40,24)} // (正是它的职责),测试就会莫名其妙失败 func execManualMerge(plan mergePlan) error { jobFinish("") - err := runMergeExclusive(func() error { return runManualMerge(plan) }) + err := runMergeExclusive(func() error { return runManualMerge(plan, 1, 1) }) jobFinish(jobErrText(err)) return err } @@ -844,3 +844,127 @@ func TestFindDanmuForVideo(t *testing.T) { } }) } + +// ==================== 一次排多个合并任务 ==================== + +// 造一组能过检查的「视频 + 弹幕」:视频随便写点字节,弹幕用真能解析的 jsonl +// 文件要落在录播目录里,因为请求里只带文件名,resolveMergeInput 按录播目录找 +func writeMergePair(t *testing.T, name string) (string, string) { + t.Helper() + video := filepath.Join(recordSaveDir(), name+".mp4") + writeTestFile(t, video, "video") + jsonl := filepath.Join(recordSaveDir(), name+".jsonl") + writeTestFile(t, jsonl, + "{\"playResX\":1920,\"playResY\":1080,\"fontSize\":48,\"startTime\":0}\n"+ + "{\"uid\":101,\"content\":\"第一条\",\"time\":1,\"lane\":0}\n") + return filepath.Base(video), filepath.Base(jsonl) +} + +// 批量请求要能拆成多个方案,且每个方案各自带上自己的参数 +func TestBuildMergePlansBatch(t *testing.T) { + setupMergeTest(t) + v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场") + v2, d2 := writeMergePair(t, "2026-09-25 02-00-00 乙 第二场") + + br1, fps2 := 1500, 60 + plans, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{ + {Videos: []string{v1}, Danmu: d1, Options: mergeOptions{Bitrate: &br1}}, + {Videos: []string{v2}, Danmu: d2, Options: mergeOptions{Fps: &fps2}}, + }}) + if err != nil { + t.Fatalf("批量检查失败:%v", err) + } + if len(plans) != 2 { + t.Fatalf("应拆成 2 个方案,实际 %d", len(plans)) + } + if plans[0].opts.bitrate() != 1500 { + t.Errorf("第一个任务的码率没带上:%d", plans[0].opts.bitrate()) + } + if plans[1].opts.bitrate() != config.MergeBitrate { + t.Errorf("第二个任务没填码率就该用全局设置,实际 %d", plans[1].opts.bitrate()) + } + if plans[1].opts.fps() != 60 { + t.Errorf("第二个任务的帧率没带上:%d", plans[1].opts.fps()) + } + // 输出名各自独立 + if plans[0].info.OutName == plans[1].info.OutName { + t.Errorf("两个任务的输出名撞了:%s", plans[0].info.OutName) + } + + // 单个任务(老写法)仍然只出一个方案 + one, err := buildMergePlans(mergeRequest{Videos: []string{v1}, Danmu: d1}) + if err != nil || len(one) != 1 { + t.Fatalf("单个任务应只出 1 个方案,实际 %d(err=%v)", len(one), err) + } +} + +// 排在后面的任务有问题时要连序号一起报出来,并且一个都不该开始 +func TestBuildMergePlansReportsBadIndex(t *testing.T) { + setupMergeTest(t) + v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场") + + _, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{ + {Videos: []string{v1}, Danmu: d1}, + {Videos: []string{v1}, Danmu: "不存在的弹幕.jsonl"}, + }}) + if err == nil { + t.Fatal("第二个任务的文件不存在,应当报错") + } + if !strings.Contains(err.Error(), "第 2 个任务") { + t.Errorf("报错里该带上序号,实际:%v", err) + } +} + +// 一次排太多要挡住,别让人不小心把几十场全排上 +func TestBuildMergePlansCapsCount(t *testing.T) { + setupMergeTest(t) + v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场") + jobs := make([]mergeJobReq, 0, maxMergeJobs+1) + for i := 0; i < maxMergeJobs+1; i++ { + jobs = append(jobs, mergeJobReq{Videos: []string{v1}, Danmu: d1}) + } + if _, err := buildMergePlans(mergeRequest{Jobs: jobs}); err == nil { + t.Errorf("超过 %d 个任务应当被挡住", maxMergeJobs) + } +} + +// 逐任务参数的兜底和校验 +func TestMergeOptionsFallbackAndValidate(t *testing.T) { + ff := testFFmpeg(t) + config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 2000, 30, 42 + defer func() { config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 0, 0, 0 }() + + var empty mergeOptions + if empty.bitrate() != 2000 || empty.fps() != 30 || empty.fontSize() != 42 { + t.Errorf("没填的项应回落到全局设置,实际 %d / %d / %d", + empty.bitrate(), empty.fps(), empty.fontSize()) + } + // 填 0 是有明确含义的,不能被当成「没填」 + zero := 0 + if (mergeOptions{Bitrate: &zero}).bitrate() != 0 { + t.Error("填了 0 就应当是 0(自动码率),不是全局设置的值") + } + if msg := (mergeOptions{Fps: &zero}).validate(ff); msg != "" { + t.Errorf("帧率 0(跟随源片)不该被挡:%s", msg) + } + if msg := (mergeOptions{Encoder: "auto"}).validate(ff); msg != "" { + t.Errorf("auto 表示自动挑,不该被挡:%s", msg) + } + bad := -1 + if msg := (mergeOptions{Bitrate: &bad}).validate(ff); msg == "" { + t.Error("负的码率应当被挡住") + } + over := maxMergeFps + 1 + if msg := (mergeOptions{Fps: &over}).validate(ff); msg == "" { + t.Error("超出上限的帧率应当被挡住") + } + if msg := (mergeOptions{Encoder: "av1_nvenc"}).validate(ff); msg == "" { + t.Error("本机没有的编码器应当被挡住") + } + // 本机可用的编码器要放行 + for _, e := range availableEncoders(ff) { + if msg := (mergeOptions{Encoder: e}).validate(ff); msg != "" { + t.Errorf("本机可用的 %s 不该被挡:%s", e, msg) + } + } +} diff --git a/notify_test.go b/notify_test.go index 4a3e5d7..666a180 100644 --- a/notify_test.go +++ b/notify_test.go @@ -727,7 +727,7 @@ func TestManualMergeSendsMergeDoneNotify(t *testing.T) { if err != nil { t.Fatalf("检查失败:%v", err) } - if err := runManualMerge(plan); err != nil { + if err := runManualMerge(plan, 1, 1); err != nil { t.Fatalf("手动合并失败:%v", err) } diff --git a/progress.go b/progress.go index b643cc4..fa39771 100644 --- a/progress.go +++ b/progress.go @@ -14,6 +14,13 @@ import ( "time" ) +// 任务类型。阶段划分按它来取(见 jobPhasePlanFor) +const ( + jobKindManualMerge = "手动合并" + jobKindAutoMerge = "自动合并" + jobKindTranscode = "转码" +) + // 任务阶段,前端按这个顺序画步骤条 var jobPhaseNames = []string{"准备", "分段拼接", "获取头像", "画面渲染", "拼接与落盘"} @@ -30,6 +37,36 @@ const ( // 其余阶段按经验估:拼接分段与头像下载是分钟级,准备和落盘是秒级。 var jobPhaseWeights = []float64{0.02, 0.08, 0.10, 0.75, 0.05} +// 一类任务的阶段划分。转码没有弹幕和头像,步骤比合并少,硬套合并那套步骤条 +// 会冒出两个永远走不到的空阶段 +type jobPhasePlan struct { + names []string + weights []float64 + render int // 哪个阶段是真正耗时的主阶段,ETA 按它算 +} + +// 合并类任务的阶段(默认),转码类任务的阶段 +var ( + mergePhasePlan = jobPhasePlan{names: jobPhaseNames, weights: jobPhaseWeights, render: jobPhaseRender} + // 转码:探测一下参数就开始编,编完落到成片目录 + transcodePhasePlan = jobPhasePlan{ + names: []string{"准备", "转码", "落盘"}, + weights: []float64{0.03, 0.92, 0.05}, + render: 1, + } +) + +// 转码任务阶段里「转码」这一步的序号 +const jobPhaseTranscode = 1 + +// 按任务类型取阶段划分,没见过的类型按合并处理 +func jobPhasePlanFor(kind string) jobPhasePlan { + if kind == jobKindTranscode { + return transcodePhasePlan + } + return mergePhasePlan +} + // 渲染进度的采样间隔与折线最多保留的点数(1 秒 1 个,够画两分钟) const ( jobSampleInterval = time.Second @@ -39,6 +76,10 @@ const ( // 任务被手动停止时各层返回这个错误:调用方据此清理临时文件,不再回退其它编码器 var errJobStopped = errors.New("任务已被手动停止") +// 起任务时任务位被别人占着:这次没有真的开始,调用方据此跳过 jobFinish, +// 不然会把正在跑的那个任务的进度卡片清掉 +var errJobNotStarted = errors.New("已经有别的任务在跑,请先等它结束") + // 速度折线上的一个点 type jobSample struct { T float64 `json:"t"` // 任务开始后的秒数 @@ -64,6 +105,8 @@ type jobInfo struct { ChunkTotal int `json:"chunkTotal"` AvatarDone int `json:"avatarDone"` AvatarTotal int `json:"avatarTotal"` + QueueDone int `json:"queueDone"` // 批量任务:已经做完第几个(从 1 开始),单任务为 0 + QueueTotal int `json:"queueTotal"` // 批量任务:一共几个 InputBytes int64 `json:"inputBytes"` // 源视频总大小 EstOutBytes int64 `json:"estOutBytes"` // 预计成片大小 EstSource string `json:"estSource"` // 预计值的来源:bitrate / measured @@ -90,6 +133,14 @@ type jobState struct { stopped bool proc *exec.Cmd // 当前在跑的 ffmpeg,暂停/停止都作用在它身上 + // 当前任务的阶段划分,随任务类型在 jobStart 时定下来 + phases []string + weights []float64 + renderIdx int + // 批量任务(手动合并队列、转码队列)的进度,单任务时都是 0 + queueDone int + queueTotal int + totalSec float64 doneSec float64 speed float64 @@ -163,6 +214,10 @@ func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool { job.active = true job.kind = kind job.outName = outName + plan := jobPhasePlanFor(kind) + job.phases = plan.names + job.weights = plan.weights + job.renderIdx = plan.render job.phaseIdx = 0 job.phasePct = 0 job.started = time.Now() @@ -175,6 +230,7 @@ func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool { job.fps = 0 job.chunkDone, job.chunkTotal = 0, 0 job.avatarDone, job.avatarTotal = 0, 0 + job.queueDone, job.queueTotal = 0, 0 job.inputBytes = inputBytes job.planOutBytes = 0 job.liveOutBytes = 0 @@ -217,7 +273,7 @@ func jobFinish(errText string) { job.doneAt = time.Now().Unix() switch errText { case "": - job.phaseIdx = len(jobPhaseNames) + job.phaseIdx = len(job.phases) job.phasePct = 1 job.message = "已完成" case errJobStopped.Error(): @@ -346,6 +402,16 @@ func jobAvatars(done, total int) { } } +// 批量任务(手动合并队列、转码队列)排到第几个。done 从 1 开始,total 是总数 +func jobQueue(done, total int) { + job.Lock() + defer job.Unlock() + if !job.active { + return + } + job.queueDone, job.queueTotal = done, total +} + // 设置任务总时长(视频秒数),ETA 和渲染百分比都靠它 func jobSetTotal(sec float64) { job.Lock() @@ -481,8 +547,12 @@ func clamp01(v float64) float64 { // 总进度:已走完的阶段按权重全算,当前阶段按阶段内比例折算 func (j *jobState) percentLocked() float64 { + weights := j.weights + if len(weights) == 0 { + weights = jobPhaseWeights + } p := 0.0 - for i, w := range jobPhaseWeights { + for i, w := range weights { switch { case i < j.phaseIdx: p += w @@ -498,7 +568,7 @@ func (j *jobState) etaLocked(now time.Time) float64 { if !j.active { return 0 } - if j.phaseIdx == jobPhaseRender && j.speed > 0 && j.totalSec > j.doneSec { + if j.phaseIdx == j.renderIdx && j.speed > 0 && j.totalSec > j.doneSec { return (j.totalSec - j.doneSec) / j.speed } pct := j.percentLocked() @@ -518,7 +588,7 @@ func jobSnapshot() jobInfo { Kind: job.kind, OutName: job.outName, PhaseIdx: job.phaseIdx, - Phases: jobPhaseNames, + Phases: job.phases, Paused: job.paused, Stopping: job.stopped, Speed: job.speed, @@ -529,6 +599,8 @@ func jobSnapshot() jobInfo { ChunkTotal: job.chunkTotal, AvatarDone: job.avatarDone, AvatarTotal: job.avatarTotal, + QueueDone: job.queueDone, + QueueTotal: job.queueTotal, InputBytes: job.inputBytes, OutBytes: job.outBytes, Message: job.message, diff --git a/transcode.go b/transcode.go new file mode 100644 index 0000000..ba63466 --- /dev/null +++ b/transcode.go @@ -0,0 +1,1025 @@ +// web UI 的转码:把录好的视频按自己的需要重新压一遍 +// 合并是「录播 + 弹幕 → 成片」,转码是「已有视频 → 重新编码的视频」, +// 两者都要占同一个任务位(界面上的进度卡片只有一份),所以串行排队 +package main + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" +) + +// 码率控制模式 +const ( + rateModeSource = "source" // 跟随源片码率(体积基本不变) + rateModeBitrate = "bitrate" // 指定目标码率 + rateModeQuality = "quality" // 质量模式:libx264 用 CRF,QSV 用 ICQ + rateModePercent = "percent" // 源片码率的百分比 +) + +// 音频处理方式 +const ( + audioCopy = "copy" // 直接拷源音轨 + audioAAC128 = "aac128" // 重编码 AAC 128k + audioAAC192 = "aac192" // 重编码 AAC 192k + audioNone = "none" // 去掉音轨 +) + +// 编码速度预设。libx264 和 QSV 都认这一组,慢一点压得小一点 +var transcodePresets = []string{"veryfast", "faster", "fast", "medium", "slow", "slower"} + +const defaultTranscodePreset = "medium" + +// 分辨率缩放的下限,再小就看不清了 +const ( + minScaleHeight = 144 + maxScaleHeight = 4320 +) + +// 一次最多排多少个转码任务 +const maxTranscodeJobs = 50 + +// 码率上限(Kbps),转码和合并的逐任务码率都用它兜底。填个单位写错的数 +// (比如把 2000 写成 2000000000)不该原样丢给 ffmpeg,那只会换来一句看不懂的报错。 +// 200 Mbps 已经远超任何录播源 +const maxBitrateKbps = 200 * 1000 + +// 单个文件的转码参数 +type transcodeOptions struct { + Encoder string `json:"encoder"` // 空或 auto = 自动挑,否则是指定的 ffmpeg 编码器名 + Mode string `json:"mode"` // 码率控制模式,空 = 跟随源片 + Bitrate int `json:"bitrate"` // 目标码率(Kbps),mode=bitrate 时用 + Quality int `json:"quality"` // 质量值(CRF / ICQ),mode=quality 时用 + Percent int `json:"percent"` // 源片码率的百分比,mode=percent 时用 + Preset string `json:"preset"` // 编码速度预设,空 = medium + Fps int `json:"fps"` // 输出帧率,0 = 跟随源片 + Scale int `json:"scale"` // 目标高度(像素),0 = 原样 + Audio string `json:"audio"` // 音频处理,空 = 直接拷贝 +} + +// 一个待转码的文件 +type transcodeJobReq struct { + Dir string `json:"dir"` // record / merge + Name string `json:"name"` // 文件名 + OutName string `json:"outName"` // 输出文件名,留空自动 + Options transcodeOptions `json:"options"` +} + +// 探测到的源文件信息,页面用它显示原始参数和算预估体积 +type transcodeProbe struct { + Name string `json:"name"` + Dir string `json:"dir"` + Size int64 `json:"size"` + Duration float64 `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + Fps float64 `json:"fps"` + VideoKbps int `json:"videoKbps"` + AudioKbps int `json:"audioKbps"` + HasAudio bool `json:"hasAudio"` + HasVideo bool `json:"hasVideo"` // 有没有真正的视频轨(只有音轨/封面图的文件排了也转不了) + VideoCodec string `json:"videoCodec"` +} + +// 体积估算结果 +type transcodeEstimate struct { + Bytes int64 `json:"bytes"` // 预估产物大小(字节) + VideoKbps int `json:"videoKbps"` // 预估视频码率 + AudioKbps int `json:"audioKbps"` // 预估音频码率 + Exact bool `json:"exact"` // 是否为按目标码率算的准数 + Note string `json:"note"` // 不准时说明依据 +} + +// 转码任务的结果 +type transcodeResult struct { + Name string `json:"name"` + OutName string `json:"outName"` + Bytes int64 `json:"bytes"` + Err string `json:"err"` // 空表示成功 +} + +func (r transcodeResult) ok() bool { return r.Err == "" } + +// 转码队列的运行状态,页面轮询它决定按钮是否可用 +var transcodeRun struct { + sync.Mutex + busy bool + total int + done int + current string + results []transcodeResult +} + +// 转码队列的进度快照 +func transcodeQueueSnapshot() (busy bool, done, total int, current string, results []transcodeResult) { + transcodeRun.Lock() + defer transcodeRun.Unlock() + out := make([]transcodeResult, len(transcodeRun.results)) + copy(out, transcodeRun.results) + return transcodeRun.busy, transcodeRun.done, transcodeRun.total, transcodeRun.current, out +} + +// ==================== 参数校验 ==================== + +// 把没填的项补成默认值,并检查填了的项。返回给用户的错误说明(空字符串表示通过) +func (o transcodeOptions) normalize() transcodeOptions { + out := o + out.Encoder = strings.TrimSpace(out.Encoder) + out.Mode = strings.TrimSpace(out.Mode) + if out.Mode == "" { + out.Mode = rateModeSource + } + out.Preset = strings.ToLower(strings.TrimSpace(out.Preset)) + if out.Preset == "" { + out.Preset = defaultTranscodePreset + } + out.Audio = strings.ToLower(strings.TrimSpace(out.Audio)) + if out.Audio == "" { + out.Audio = audioCopy + } + return out +} + +func (o transcodeOptions) validate(ffmpegFile string) string { + n := o.normalize() + if n.Encoder != "" && n.Encoder != "auto" { + found := false + for _, e := range availableEncoders(ffmpegFile) { + if e == n.Encoder { + found = true + break + } + } + if !found { + return fmt.Sprintf("本机没有可用的编码器 %s,可用的是:%s", + n.Encoder, strings.Join(availableEncoders(ffmpegFile), "、")) + } + } + switch n.Mode { + case rateModeSource, rateModeBitrate, rateModeQuality, rateModePercent: + default: + return "码率控制模式只能是 source、bitrate、quality 或 percent" + } + if n.Mode == rateModeBitrate && (n.Bitrate <= 0 || n.Bitrate > maxBitrateKbps) { + return fmt.Sprintf("目标码率要在 1 到 %d(Kbps)之间", maxBitrateKbps) + } + if n.Mode == rateModeQuality && (n.Quality < 0 || n.Quality > 51) { + return "质量值只能在 0 到 51 之间(数字越小画质越好、体积越大)" + } + if n.Mode == rateModePercent && (n.Percent < 5 || n.Percent > 300) { + return "码率比例只能在 5% 到 300% 之间" + } + ok := false + for _, p := range transcodePresets { + if n.Preset == p { + ok = true + break + } + } + if !ok { + return "预设只能是 " + strings.Join(transcodePresets, "、") + } + if n.Fps < 0 || n.Fps > maxMergeFps { + return fmt.Sprintf("输出帧率只能在 0(跟随源片)到 %d 之间", maxMergeFps) + } + if n.Scale != 0 && (n.Scale < minScaleHeight || n.Scale > maxScaleHeight) { + return fmt.Sprintf("缩放高度只能在 %d 到 %d 之间,0 表示原样", minScaleHeight, maxScaleHeight) + } + switch n.Audio { + case audioCopy, audioAAC128, audioAAC192, audioNone: + default: + return "音频处理只能是 copy、aac128、aac192 或 none" + } + return "" +} + +// 音频码率(Kbps) +func audioKbpsOf(mode string, srcAudio int) int { + switch mode { + case audioAAC128: + return 128 + case audioAAC192: + return 192 + case audioNone: + return 0 + } + // 直接拷贝:探测不到源音轨码率时按 128 估 + if srcAudio > 0 { + return srcAudio + } + return defaultAudioKbps +} + +// 探测不到音轨码率时的兜底值 +const defaultAudioKbps = 128 + +// ==================== 体积估算 ==================== + +// 质量模式的码率系数:以 CRF 18 为基准(≈ 源片同画质),数值越大压得越狠。 +// 实测参考:x264 CRF 23 大约是 CRF 18 的六成体积 +var qualityFactors = []struct { + quality int + factor float64 +}{ + {0, 3.20}, {12, 1.90}, {16, 1.35}, {18, 1.00}, {20, 0.80}, + {22, 0.65}, {23, 0.58}, {25, 0.47}, {28, 0.36}, {32, 0.25}, {51, 0.10}, +} + +// 质量模式下的码率系数,表里的点之间线性插值 +func qualityFactor(q int) float64 { + if q <= qualityFactors[0].quality { + return qualityFactors[0].factor + } + last := qualityFactors[len(qualityFactors)-1] + if q >= last.quality { + return last.factor + } + for i := 1; i < len(qualityFactors); i++ { + b := qualityFactors[i] + if q > b.quality { + continue + } + a := qualityFactors[i-1] + return a.factor + (b.factor-a.factor)*float64(q-a.quality)/float64(b.quality-a.quality) + } + return 1 +} + +// HEVC 相对 H.264 的码率系数:同画质大约能省三到四成 +const hevcRateFactor = 0.65 + +// 源片视频码率(不含音频)。三级兜底: +// 1. 视频流自己写了码率,直接用; +// 2. 只有容器总码率(ffmpeg 对 mp4 一般只打这个,里面含音频),扣掉音轨; +// 3. 都没有就按「文件体积 ÷ 时长」倒推,再扣掉音轨。 +// +// 三级都用不上返回 0,调用方据此退回质量模式。 +// +// 为什么要分得这么细:容器总码率是含音频的,直接当成视频码率用会有两处错 —— +// 预估体积时又加了一遍音轨(算成双份),「跟随源片」时也把音轨的码率也算进视频里。 +func videoKbpsOf(m mediaInfo, size int64) int { + if m.videoKbps > 0 { + return m.videoKbps + } + if m.containerKbps > 0 { + if v := m.containerKbps - audioPartOf(m); v > 0 { + return v + } + } + if m.duration <= 0 || size <= 0 { + return 0 + } + total := int(float64(size) * 8 / 1000 / m.duration) + v := total - audioPartOf(m) + if v < 100 { + return 0 + } + return v +} + +// 容器总码率里音频占的那部分。没有音轨就是 0,有音轨但探测不到码率时给个经验值 +func audioPartOf(m mediaInfo) int { + if !m.hasAudio { + return 0 + } + if m.audioKbps > 0 { + return m.audioKbps + } + return defaultAudioKbps +} + +// 分辨率缩放带来的码率变化:像素数按比例缩放 +func scaleRateFactor(src transcodeProbe, targetH int) float64 { + if targetH <= 0 || src.Height <= 0 { + return 1 + } + if targetH >= src.Height { + return 1 + } + r := float64(targetH) / float64(src.Height) + return r * r +} + +// 估算转码产物的大小。目标码率模式算出来的是准数,质量模式只能给个粗估 +func estimateTranscode(o transcodeOptions, p transcodeProbe, enc string) transcodeEstimate { + n := o.normalize() + srcV := p.VideoKbps // 调用方已用 videoKbpsOf 填好,这里不再自己兜底 + + var vKbps float64 + exact := true + note := "" + switch n.Mode { + case rateModeBitrate: + vKbps = float64(n.Bitrate) + case rateModePercent: + vKbps = float64(srcV) * float64(n.Percent) / 100 + case rateModeQuality: + vKbps = float64(srcV) * qualityFactor(n.Quality) + if isHEVCCodec(enc) { + vKbps *= hevcRateFactor + } + exact = false + note = "质量模式的体积由画面内容决定,这里是粗估" + default: // 跟随源片 + vKbps = float64(srcV) + if isHEVCCodec(enc) { + vKbps *= hevcRateFactor + exact = false + note = "同码率下 HEVC 一般比 H.264 省三到四成,实际看画面" + } + } + // 缩放后按像素比例再调一次(目标码率 / 比例模式已经体现过意图的除外) + if n.Mode != rateModeBitrate && n.Mode != rateModePercent { + vKbps *= scaleRateFactor(p, n.Scale) + } + // 「跟随源片 / 按比例」都靠源片码率,探不到时实际会退回质量模式, + // 别让界面显示一个看着很准其实落不了地的数 + if srcV <= 0 && (n.Mode == rateModeSource || n.Mode == rateModePercent) { + exact = false + note = "源片码率探测不到,实际会退回质量模式" + } + vKbps = math.Round(vKbps) + aKbps := audioKbpsOf(n.Audio, p.AudioKbps) + // 源片本来就没有音轨时,拷贝也拷不出音轨来,别凭空加一份音频码率 + if !p.HasAudio && n.Audio == audioCopy { + aKbps = 0 + } + + bytes := int64(0) + if p.Duration > 0 { + bytes = int64((vKbps + float64(aKbps)) * 1000 / 8 * p.Duration) + } + return transcodeEstimate{ + Bytes: bytes, + VideoKbps: int(vKbps), + AudioKbps: aKbps, + Exact: exact, + Note: note, + } +} + +// ==================== 探测 ==================== +// +// 这里是一次探测拿全(时长 / 分辨率 / 帧率 / 视频码率 / 音轨)的底层实现, +// 合并那边的 probeDuration 与 probeVideoStream 也走它 —— 同一段 ffmpeg 输出解析一遍就够了, +// 之前为了拿时长和视频流各起一次 ffmpeg,等于白跑一个进程。 + +// 媒体文件的关键参数 +type mediaInfo struct { + duration float64 + width int + height int + fps float64 + videoKbps int // 视频流自己写的码率(Kbps),0 表示流里没写 + // 容器总码率(Kbps,含音频)。mp4 这类容器 ffmpeg 经常只在 Duration 行打总码率, + // 这时它是视频流的唯一线索,但不能直接当视频码率用(见 videoKbpsOf) + containerKbps int + codec string + audioKbps int + hasAudio bool + hasVideo bool // 有真正的视频轨(封面图 attached pic 不算) +} + +// 探测一个媒体文件。探测不到时长但能拿到画面信息时也算成功(有些流没有 Duration) +func probeMedia(ffmpegFile, path string) mediaInfo { + cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path) + var buf strings.Builder + cmd.Stderr = &buf + // 没指定输出文件时 ffmpeg 返回非 0,但流信息已经打印出来了 + _ = cmd.Run() + return parseMediaInfo(buf.String()) +} + +// 同一个文件在短时间内被反复探测是常态:体积估算跟着输入框重发、合并里先后问时长和视频流。 +// 每次都起一个 ffmpeg 进程的话,光排队就能把 CPU 占住,所以按「ffmpeg + 路径 + 体积 + 修改时间」 +// 记住上一次的结果。文件一变(录制中追加、换了个文件)体积或修改时间就变了,自然会重新探测。 +var probeCache struct { + sync.Mutex + m map[string]mediaInfo +} + +// 缓存上限,超过就整张丢掉重来(探测量本身很小,够用就行) +const probeCacheMax = 128 + +func probeMediaCached(ffmpegFile, path string) mediaInfo { + st, err := os.Stat(path) + if err != nil { + // 拿不到体积和修改时间就没法判断文件有没有变,老老实实探一次 + return probeMedia(ffmpegFile, path) + } + key := ffmpegFile + "\x00" + path + "\x00" + + strconv.FormatInt(st.Size(), 10) + "\x00" + strconv.FormatInt(st.ModTime().UnixNano(), 10) + + probeCache.Lock() + if m, ok := probeCache.m[key]; ok { + probeCache.Unlock() + return m + } + probeCache.Unlock() + + m := probeMedia(ffmpegFile, path) + // 什么都没探到的不缓存:那多半是这次读文件失败或 ffmpeg 忙, + // 记下来会让后面一直用这份空结论 + if m.duration > 0 || m.width > 0 { + probeCache.Lock() + if probeCache.m == nil || len(probeCache.m) >= probeCacheMax { + probeCache.m = make(map[string]mediaInfo) + } + probeCache.m[key] = m + probeCache.Unlock() + } + return m +} + +// 解析 ffmpeg -i 的输出。视频、音频、容器的信息在同一段文本里,一次解析完 +func parseMediaInfo(out string) mediaInfo { + var info mediaInfo + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "Duration:") { + if m := durationRe.FindStringSubmatch(line); len(m) == 4 { + h, _ := strconv.Atoi(m[1]) + mi, _ := strconv.Atoi(m[2]) + s, _ := strconv.ParseFloat(m[3], 64) + info.duration = float64(h)*3600 + float64(mi)*60 + s + } + // 容器总码率(含音频),视频流自己没写码率时用它 + if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 { + info.containerKbps, _ = strconv.Atoi(m[1]) + } + continue + } + // 封面图(mjpeg 之类的 attached pic)也是一条 Video 流,但它不是画面, + // 拿它的分辨率当画布会把弹幕层算错,所以整条跳过 + if strings.Contains(line, "Video:") && !strings.Contains(line, "attached pic") { + info.hasVideo = true + if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 { + info.width, _ = strconv.Atoi(m[1]) + info.height, _ = strconv.Atoi(m[2]) + } + if m := videoBitrateRe.FindStringSubmatch(line); len(m) == 2 { + info.videoKbps, _ = strconv.Atoi(m[1]) + } + if m := videoFpsRe.FindStringSubmatch(line); len(m) == 2 { + info.fps, _ = strconv.ParseFloat(m[1], 64) + } + if m := videoCodecRe.FindStringSubmatch(line); len(m) == 2 { + info.codec = m[1] + } + continue + } + if strings.Contains(line, "Audio:") { + info.hasAudio = true + if m := audioBitrateRe.FindStringSubmatch(line); len(m) == 2 { + info.audioKbps, _ = strconv.Atoi(m[1]) + } + } + } + return info +} + +// 视频流自己没写码率时退回容器总码率(含音频)。 +// 合并那边的自动码率一直按这个口径算(「源片码率 + 弹幕余量」),别改成扣掉音频的版本, +// 否则所有自动合并的成片都会莫名变小一截 +func streamOrContainerKbps(m mediaInfo) int { + if m.videoKbps > 0 { + return m.videoKbps + } + return m.containerKbps +} + +var ( + // 「Stream #0:1(und): Video: h264 (High) ...」里的编码器名 + videoCodecRe = regexp.MustCompile(`Video: ([a-z0-9_]+)`) + // 「Stream #0:1: Audio: aac (LC) ... 192 kb/s」 + audioBitrateRe = regexp.MustCompile(`Audio:.*? (\d+) kb/s`) + // 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分 + videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`) + // 视频流自己的码率,形如「1920x1080 [SAR 1:1 DAR 16:9], 331 kb/s, 30 fps」 + 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`) + // 帧率,形如「1920x1080, 30 fps, 30 tbr, 1k tbn」 + videoFpsRe = regexp.MustCompile(`, (\d+(?:\.\d+)?) fps`) + // 时长,形如「Duration: 00:02:55.07」 + durationRe = regexp.MustCompile(`Duration: (\d+):(\d+):(\d+\.\d+)`) +) + +// 日志里打印的体积,例如 1.2 GB +func formatByteSize(n int64) string { + if n <= 0 { + return "0 B" + } + units := []string{"B", "KB", "MB", "GB", "TB"} + v := float64(n) + i := 0 + for v >= 1024 && i < len(units)-1 { + v /= 1024 + i++ + } + if i == 0 { + return fmt.Sprintf("%d B", n) + } + return fmt.Sprintf("%.1f %s", v, units[i]) +} + +// 转码任务的输入必须来自文件管理里的两个文件夹,和手动合并一致 +func resolveTranscodeInput(dir, name string) (string, error) { + full, err := resolveFile(dir, name) + if err != nil { + return "", err + } + if _, ok := mergeVideoExts[strings.ToLower(filepath.Ext(full))]; !ok { + return "", fmt.Errorf("不支持的视频格式 %s,支持 %s", filepath.Ext(full), extListText(mergeVideoExts)) + } + return full, nil +} + +// ==================== ffmpeg 命令 ==================== + +// 这一帧用哪个编码器:指定了就用指定的,否则按本机可用性自动挑 +func pickTranscodeEncoder(o transcodeOptions, ffmpegFile string) string { + if enc := strings.TrimSpace(o.Encoder); enc != "" && enc != "auto" { + return enc + } + avail := availableEncoders(ffmpegFile) + if len(avail) == 0 { + return "libx264" + } + return avail[0] +} + +// 本次转码的视频码率(Kbps),0 表示交给编码器自己按质量模式决定。 +// src.videoKbps 由 runTranscode 用 videoKbpsOf 填好;填不上(探不到源片码率)时 +// 「按比例」和「跟随源片」都只能返回 0,于是落到质量模式 —— 这件事要在日志里说出来 +func transcodeVideoKbps(o transcodeOptions, src mediaInfo) int { + n := o.normalize() + switch n.Mode { + case rateModeBitrate: + return n.Bitrate + case rateModePercent: + return int(math.Round(float64(src.videoKbps) * float64(n.Percent) / 100)) + case rateModeQuality: + return 0 + } + return src.videoKbps +} + +// 质量模式没填数值时的默认值:QSV 用 ICQ,软编用 CRF +func defaultQuality(enc string) int { + if strings.HasSuffix(enc, "_qsv") { + return 22 + } + return 23 +} + +// 拼 ffmpeg 的参数。返回参数、实际用的编码器和输出帧率 +func transcodeArgs(ffmpegFile, in, out string, o transcodeOptions, src mediaInfo) ([]string, string, int) { + n := o.normalize() + enc := pickTranscodeEncoder(n, ffmpegFile) + + args := []string{"-hide_banner", "-y", "-i", in} + + // 缩放和帧率都走 -vf:缩放按高度算宽度(-2 保证是偶数), + // 帧率这里允许往下降(转码就是要压体积,降帧是用户的明确选择) + var filters []string + scaleH := n.Scale + if scaleH > 0 && src.height > 0 && scaleH >= src.height { + scaleH = 0 // 不放大小图,放大只会白白增加体积 + } + if scaleH > 0 { + filters = append(filters, fmt.Sprintf("scale=-2:%d", scaleH)) + } + outFps := 0 + if n.Fps > 0 && (src.fps <= 0 || math.Abs(float64(n.Fps)-src.fps) > 0.01) { + outFps = n.Fps + filters = append(filters, fmt.Sprintf("fps=%d", outFps)) + } + if len(filters) > 0 { + args = append(args, "-vf", strings.Join(filters, ",")) + } + + args = append(args, "-map", "0:v:0", "-c:v", enc) + if vbr := transcodeVideoKbps(n, src); vbr > 0 { + args = append(args, "-b:v", strconv.Itoa(vbr)+"k") + } else { + // 质量模式。没填数值时按编码器给个常用默认:QSV 用 ICQ,软编用 CRF + q := n.Quality + if q <= 0 { + q = defaultQuality(enc) + } + if strings.HasSuffix(enc, "_qsv") { + args = append(args, "-global_quality", strconv.Itoa(q)) + } else { + args = append(args, "-crf", strconv.Itoa(q)) + } + } + args = append(args, "-preset", n.Preset) + // HEVC 在 mp4 里默认写成 hev1,Apple 系不认,统一改 hvc1 + if isHEVCCodec(enc) { + args = append(args, "-tag:v", "hvc1") + } + + switch n.Audio { + case audioNone: + args = append(args, "-an") + case audioAAC128: + args = append(args, "-map", "0:a?", "-c:a", "aac", "-b:a", "128k") + case audioAAC192: + args = append(args, "-map", "0:a?", "-c:a", "aac", "-b:a", "192k") + default: + args = append(args, "-map", "0:a?", "-c:a", "copy") + } + args = append(args, "-movflags", "+faststart", out) + return args, enc, outFps +} + +// ==================== 执行 ==================== + +// 转码一个文件,返回实际落地的成片文件名。idx / total 是队列里的位置(单任务传 1,1) +func runTranscode(job transcodeJobReq, outDir string, idx, total int) (string, error) { + ffmpegFile := getFFmpeg() + if ffmpegFile == "" { + return "", errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径") + } + in, err := resolveTranscodeInput(job.Dir, job.Name) + if err != nil { + return "", err + } + if isFileActive(in) { + return "", fmt.Errorf("%s 正在录制或写入中,等它结束再转码", filepath.Base(in)) + } + info, err := os.Stat(in) + if err != nil { + return "", fmt.Errorf("读不到文件信息:%s", filepath.Base(in)) + } + + src := probeMediaCached(ffmpegFile, in) + if src.duration <= 0 { + return "", fmt.Errorf("读不出 %s 的时长,没法预估进度", filepath.Base(in)) + } + if !src.hasVideo { + return "", fmt.Errorf("%s 里没有视频轨(只有音轨或封面图),没法转码", filepath.Base(in)) + } + // 源片视频码率探到一次就固定下来:预估体积和实际拼出来的 -b:v 必须用同一个数 + // (这里填不上时「按比例 / 跟随源片」会退回质量模式,日志里会说一声) + src.videoKbps = videoKbpsOf(src, info.Size()) + + enc := pickTranscodeEncoder(job.Options, ffmpegFile) + // 默认名:`原名_转码.mp4`。用户没改名字时自动避开重名,改了就按他填的来 + base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in)) + autoName := base + "_转码.mp4" + outName := strings.TrimSpace(job.OutName) + if outName == "" || outName == autoName { + outName = filepath.Base(freeOutPath(outDir, base+"_转码")) + } else { + clean, err := sanitizeOutName(outName) + if err != nil { + return "", err + } + outName = clean + } + out := filepath.Join(outDir, outName) + + if !jobStart(jobKindTranscode, outName, info.Size(), src.duration) { + return "", errJobNotStarted + } + if total > 0 { + jobQueue(idx, total) + } + // 转码的目标码率是明确的,先把预估体积登记上,界面在 ffmpeg 出数之前就有东西看 + probe := transcodeProbe{ + Name: filepath.Base(in), Size: info.Size(), Duration: src.duration, + Width: src.width, Height: src.height, Fps: src.fps, + VideoKbps: src.videoKbps, AudioKbps: src.audioKbps, HasAudio: src.hasAudio, + VideoCodec: src.codec, + } + jobPlanSize(estimateTranscode(job.Options, probe, enc).Bytes) + + // 先在本地(源文件所在目录)转,完成后再落到输出目录: + // 输出目录可能是网络盘或外接盘,直接往里写、写到一半失败会留个半成品 + tmpOut := tempMergeOutput(in, filepath.Base(out)) + args, enc, outFps := transcodeArgs(ffmpegFile, in, tmpOut, job.Options, src) + + tag := "" + if total > 1 { + tag = fmt.Sprintf("(第 %d/%d 个)", idx, total) + } + // 「跟随源片」和「按比例」都要用源片码率,探不到就只能落到质量模式,日志里说清楚, + // 不然用户会以为按 60% 压的、结果成片体积对不上 + if m := job.Options.normalize().Mode; (m == rateModeSource || m == rateModePercent) && transcodeVideoKbps(job.Options, src) <= 0 { + lPrintWarn("源片码率探测不到,「跟随源片 / 按比例」这次改用质量模式(CRF / ICQ)") + } + lPrintf("转码开始%s:%s → %s,编码器 %s,%dx%d → %s,%s", + tag, filepath.Base(in), outName, enc, src.width, src.height, + transcodeBrief(job.Options, src, outFps), formatByteSize(info.Size())) + lPrintf("先在本地生成 %s,完成后落到 %s", filepath.Base(tmpOut), out) + + jobSetPhase(jobPhaseTranscode) + markFileActive(tmpOut, true) + err = runFFmpegWith(ffmpegFile, args, filepath.Dir(in), func(outSec, speed, fps float64, size int64) { + jobRender(outSec, speed, fps, size) + }) + markFileActive(tmpOut, false) + if err != nil { + _ = os.Remove(tmpOut) + if jobStopRequested() { + return "", errJobStopped + } + return "", fmt.Errorf("ffmpeg 运行失败:%v", err) + } + + jobSetPhase(len(transcodePhasePlan.names) - 1) + if err := placeOutput(tmpOut, out); err != nil { + _ = os.Remove(tmpOut) + return "", err + } + if _, err := os.Stat(out); err == nil { + jobRender(src.duration, 0, 0, fileSizeOrZero(out)) + } + lPrintln("转码完成" + tag + ":" + out) + return outName, nil +} + +// 日志里的一句参数摘要,方便事后对照「这一个是按什么压的」 +func transcodeBrief(o transcodeOptions, src mediaInfo, outFps int) string { + n := o.normalize() + parts := []string{} + switch n.Mode { + case rateModeBitrate: + parts = append(parts, fmt.Sprintf("目标码率 %d kbps", n.Bitrate)) + case rateModePercent: + parts = append(parts, fmt.Sprintf("源片码率的 %d%%", n.Percent)) + case rateModeQuality: + parts = append(parts, fmt.Sprintf("质量值 %d", n.Quality)) + default: + parts = append(parts, "跟随源片码率") + } + if outFps > 0 { + parts = append(parts, fmt.Sprintf("%d fps(源片 %.2f)", outFps, src.fps)) + } + if n.Scale > 0 && src.height > 0 && n.Scale < src.height { + parts = append(parts, fmt.Sprintf("缩放到 %dp", n.Scale)) + } + if n.Audio != audioCopy { + parts = append(parts, "音频 "+n.Audio) + } + parts = append(parts, "预设 "+n.Preset) + return strings.Join(parts, ",") +} + +// ==================== 接口 ==================== + +// POST /api/transcode/probe:批量探测源文件的参数,顺便把本机可用的编码器带回去 +func transcodeProbeHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Files []transcodeJobReq `json:"files"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if len(req.Files) == 0 { + http.Error(w, "请先选择要转码的文件", http.StatusBadRequest) + return + } + if len(req.Files) > maxTranscodeJobs { + http.Error(w, fmt.Sprintf("一次最多选 %d 个文件", maxTranscodeJobs), http.StatusBadRequest) + return + } + ffmpegFile := getFFmpeg() + if ffmpegFile == "" { + http.Error(w, "找不到 ffmpeg,请先在设置里填写 ffmpeg 路径", http.StatusBadRequest) + return + } + + out := make([]transcodeProbe, 0, len(req.Files)) + for _, f := range req.Files { + full, err := resolveTranscodeInput(f.Dir, f.Name) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + st, err := os.Stat(full) + if err != nil { + http.Error(w, "读不到文件信息:"+filepath.Base(full), http.StatusBadRequest) + return + } + m := probeMediaCached(ffmpegFile, full) + out = append(out, transcodeProbe{ + Name: filepath.Base(full), Dir: f.Dir, Size: st.Size(), + Duration: m.duration, Width: m.width, Height: m.height, Fps: m.fps, + // 视频码率在这里就扣掉音轨:后面预估体积还要单独加一次音频, + // 直接用容器总码率会把音轨算成两份 + VideoKbps: videoKbpsOf(m, st.Size()), AudioKbps: m.audioKbps, HasAudio: m.hasAudio, + HasVideo: m.hasVideo, VideoCodec: m.codec, + }) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "files": out, + "encoders": availableEncoders(ffmpegFile), + "outDir": absPath(mergeSaveDir()), + }); err != nil { + lPrintErrf("输出转码探测结果失败:%v", err) + } +} + +// POST /api/transcode/estimate:按当前参数估算每个文件的产物大小 +func transcodeEstimateHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Jobs []transcodeJobReq `json:"jobs"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if len(req.Jobs) > maxTranscodeJobs { + http.Error(w, fmt.Sprintf("一次最多估 %d 个文件", maxTranscodeJobs), http.StatusBadRequest) + return + } + ffmpegFile := getFFmpeg() + + out := make([]transcodeEstimate, 0, len(req.Jobs)) + for _, j := range req.Jobs { + if msg := j.Options.validate(ffmpegFile); msg != "" { + http.Error(w, j.Name+":"+msg, http.StatusBadRequest) + return + } + full, err := resolveTranscodeInput(j.Dir, j.Name) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + st, err := os.Stat(full) + if err != nil { + http.Error(w, "读不到文件信息:"+filepath.Base(full), http.StatusBadRequest) + return + } + m := probeMediaCached(ffmpegFile, full) + p := transcodeProbe{ + Name: filepath.Base(full), Size: st.Size(), Duration: m.duration, + Width: m.width, Height: m.height, Fps: m.fps, + VideoKbps: videoKbpsOf(m, st.Size()), AudioKbps: m.audioKbps, HasAudio: m.hasAudio, + } + out = append(out, estimateTranscode(j.Options, p, pickTranscodeEncoder(j.Options, ffmpegFile))) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{"jobs": out}); err != nil { + lPrintErrf("输出转码体积估算失败:%v", err) + } +} + +// POST /api/transcode:开始转码,立刻返回,编码在后台串行进行 +func transcodeHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + OutDir string `json:"outDir"` // 留空用成片目录 + Jobs []transcodeJobReq `json:"jobs"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if len(req.Jobs) == 0 { + http.Error(w, "请先选择要转码的文件", http.StatusBadRequest) + return + } + if len(req.Jobs) > maxTranscodeJobs { + http.Error(w, fmt.Sprintf("一次最多排 %d 个转码任务", maxTranscodeJobs), http.StatusBadRequest) + return + } + ffmpegFile := getFFmpeg() + if ffmpegFile == "" { + http.Error(w, "找不到 ffmpeg,请先在设置里填写 ffmpeg 路径", http.StatusBadRequest) + return + } + // 逐个检查参数和源文件,有问题的在开跑之前就报出来,别跑到第三个才失败。 + // 探测走缓存,刚才「加入列表」和「估算体积」时已经探过,这里不会再多起 ffmpeg + for _, j := range req.Jobs { + if msg := j.Options.validate(ffmpegFile); msg != "" { + http.Error(w, j.Name+":"+msg, http.StatusBadRequest) + return + } + full, err := resolveTranscodeInput(j.Dir, j.Name) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m := probeMediaCached(ffmpegFile, full) + if isFileActive(full) { + http.Error(w, filepath.Base(full)+" 正在录制或写入中,等它结束再转码", http.StatusBadRequest) + return + } + if !m.hasVideo { + http.Error(w, filepath.Base(full)+" 里没有视频轨(只有音轨或封面图),没法转码", http.StatusBadRequest) + return + } + if m.duration <= 0 { + http.Error(w, "读不出 "+filepath.Base(full)+" 的时长,没法预估进度", http.StatusBadRequest) + return + } + } + + outDir := strings.TrimSpace(req.OutDir) + if outDir == "" { + outDir = mergeSaveDir() + } else { + if msg := checkWebDir(outDir, "输出目录"); msg != "" { + http.Error(w, msg, http.StatusBadRequest) + return + } + // 归一成绝对路径:相对路径会按进程的工作目录解析,写进日志和结果里都容易看错 + outDir = absPath(outDir) + } + + transcodeRun.Lock() + if transcodeRun.busy { + transcodeRun.Unlock() + http.Error(w, "已经有一个转码队列在跑,等它结束再说", http.StatusConflict) + return + } + // 进度卡片只有一份,合并也可能占着它 + if !tryTakeMergeSlot() { + transcodeRun.Unlock() + busy := jobBusyName() + if busy == "" { + busy = "另一个任务" + } + http.Error(w, "已经有一个任务在进行("+busy+"),等它结束再说", http.StatusConflict) + return + } + transcodeRun.busy = true + transcodeRun.total = len(req.Jobs) + transcodeRun.done = 0 + transcodeRun.current = req.Jobs[0].Name + transcodeRun.results = nil + transcodeRun.Unlock() + + total := len(req.Jobs) + safeGo("转码", func() { + defer releaseMergeSlot() + defer func() { + transcodeRun.Lock() + transcodeRun.busy = false + transcodeRun.current = "" + transcodeRun.Unlock() + }() + for i, j := range req.Jobs { + transcodeRun.Lock() + transcodeRun.current = j.Name + transcodeRun.done = i + transcodeRun.Unlock() + + outName, err := runTranscode(j, outDir, i+1, total) + // 这次没起成任务(任务位被别人占着)就别收尾,否则会把别人的进度清掉 + if err != errJobNotStarted { + jobFinish(jobErrText(err)) + } + res := transcodeResult{Name: j.Name} + if err == nil { + res.OutName = filepath.Base(outName) + res.Bytes = fileSizeOrZero(filepath.Join(outDir, outName)) + } else { + res.Err = err.Error() + if err == errJobStopped { + lPrintWarn("转码已被手动停止,剩下的文件不再处理") + } else if err != errJobNotStarted { + lPrintErrf("转码失败:%v", err) + } + } + transcodeRun.Lock() + transcodeRun.results = append(transcodeRun.results, res) + transcodeRun.Unlock() + if err == errJobStopped { + break + } + } + transcodeRun.Lock() + transcodeRun.done = len(transcodeRun.results) + ok, failed := 0, 0 + for _, r := range transcodeRun.results { + if r.ok() { + ok++ + } else { + failed++ + } + } + transcodeRun.Unlock() + if total > 1 { + lPrintf("转码队列结束:共 %d 个,成功 %d 个,失败 %d 个", total, ok, failed) + } + }) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"count":` + strconv.Itoa(total) + `}`)) +} diff --git a/transcode_test.go b/transcode_test.go new file mode 100644 index 0000000..31fe5e7 --- /dev/null +++ b/transcode_test.go @@ -0,0 +1,513 @@ +package main + +import ( + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// ==================== 参数校验 ==================== + +// 没填的项要补成默认值,不然拼出来的 ffmpeg 命令会带着一堆空参数 +func TestTranscodeOptionsNormalize(t *testing.T) { + o := transcodeOptions{} + n := o.normalize() + if n.Mode != rateModeSource { + t.Errorf("模式空时应按「跟随源片」,实际 %q", n.Mode) + } + if n.Preset != defaultTranscodePreset { + t.Errorf("预设空时应取 %q,实际 %q", defaultTranscodePreset, n.Preset) + } + if n.Audio != audioCopy { + t.Errorf("音频空时应按直接拷贝,实际 %q", n.Audio) + } +} + +// 各种填错的情况都要在开跑之前挡住,别等 ffmpeg 报一句看不懂的错 +func TestTranscodeOptionsValidate(t *testing.T) { + ff := testFFmpeg(t) + bad := map[string]transcodeOptions{ + "模式不认识": {Mode: "xyz"}, + "选了码率却没填": {Mode: rateModeBitrate}, + "质量值越界": {Mode: rateModeQuality, Quality: 99}, + "比例越界": {Mode: rateModePercent, Percent: 1000}, + "码率上界越界": {Mode: rateModeBitrate, Bitrate: maxBitrateKbps + 1}, + "预设不认识": {Preset: "ultrafast"}, + "帧率越界": {Fps: maxMergeFps + 1}, + "缩放高度太小": {Scale: 10}, + "音频处理不认识": {Audio: "mp3"}, + "编码器本机没有": {Encoder: "av1_nvenc"}, + } + for name, o := range bad { + if msg := o.validate(ff); msg == "" { + t.Errorf("%s:应当被挡住,实际通过了", name) + } + } + // 正常参数要能过 + ok := transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Preset: "fast", Fps: 30, Scale: 720, Audio: audioAAC128} + if msg := ok.validate(ff); msg != "" { + t.Errorf("正常参数不该被挡:%s", msg) + } + if msg := (transcodeOptions{Encoder: "auto"}).validate(ff); msg != "" { + t.Errorf("auto 表示自动挑,不该被挡:%s", msg) + } +} + +// ==================== 体积估算 ==================== + +// 目标码率模式的体积是能算准的:码率 × 时长 +func TestEstimateTranscodeBitrateMode(t *testing.T) { + p := transcodeProbe{Duration: 100, VideoKbps: 4000, AudioKbps: 128} + got := estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioAAC128}, p, "h264_qsv") + // 视频 2000 + 音频 128 = 2128 kbps × 100 秒 = 26.6 MB + want := int64((2000 + 128) * 1000 / 8 * 100) + if got.Bytes != want { + t.Errorf("预估体积应为 %d,实际 %d", want, got.Bytes) + } + if !got.Exact { + t.Error("目标码率模式算出来的是准数,不该标成粗估") + } + if got.VideoKbps != 2000 || got.AudioKbps != 128 { + t.Errorf("预估码率不对:视频 %d、音频 %d", got.VideoKbps, got.AudioKbps) + } +} + +// 按比例模式:60% 就是源片的六成 +func TestEstimateTranscodePercentMode(t *testing.T) { + p := transcodeProbe{Duration: 60, VideoKbps: 5000} + got := estimateTranscode(transcodeOptions{Mode: rateModePercent, Percent: 60, Audio: audioNone}, p, "libx264") + want := int64(3000 * 1000 / 8 * 60) + if got.Bytes != want { + t.Errorf("60%% 的源片码率应为 %d,实际 %d", want, got.Bytes) + } + if got.AudioKbps != 0 { + t.Errorf("去掉音轨后音频码率应为 0,实际 %d", got.AudioKbps) + } +} + +// 质量模式只能粗估:数值越大体积越小,且要标成粗估 +func TestEstimateTranscodeQualityMode(t *testing.T) { + p := transcodeProbe{Duration: 100, VideoKbps: 4000, AudioKbps: 128} + low := estimateTranscode(transcodeOptions{Mode: rateModeQuality, Quality: 18}, p, "libx264") + high := estimateTranscode(transcodeOptions{Mode: rateModeQuality, Quality: 28}, p, "libx264") + if low.Bytes <= high.Bytes { + t.Errorf("质量值 18(%d 字节)应比 28(%d 字节)体积大", low.Bytes, high.Bytes) + } + if low.Exact || high.Exact { + t.Error("质量模式只能粗估,不该标成准数") + } + if high.Note == "" { + t.Error("粗估要给出依据说明,界面上要能看出来这不是准数") + } +} + +// 质量值的系数表要单调:数值越大系数越小 +func TestQualityFactorMonotonic(t *testing.T) { + prev := math.Inf(1) + for q := 0; q <= 51; q++ { + f := qualityFactor(q) + if f > prev+1e-9 { + t.Fatalf("质量值 %d 的系数 %v 比上一个还大,压得越狠体积越大了", q, f) + } + if f <= 0 { + t.Fatalf("质量值 %d 的系数不该是 %v", q, f) + } + prev = f + } + // 表上的点要精确命中 + if got := qualityFactor(18); math.Abs(got-1.0) > 1e-9 { + t.Errorf("CRF 18 是基准,系数应为 1,实际 %v", got) + } +} + +// 缩到更低分辨率后,同样的码率要求下产物应更小 +func TestEstimateTranscodeScalingShrinks(t *testing.T) { + p := transcodeProbe{Duration: 100, VideoKbps: 4000, Height: 1080} + full := estimateTranscode(transcodeOptions{Mode: rateModeSource}, p, "libx264") + half := estimateTranscode(transcodeOptions{Mode: rateModeSource, Scale: 540}, p, "libx264") + if half.Bytes >= full.Bytes { + t.Errorf("缩到 540p(%d)应比原样(%d)小", half.Bytes, full.Bytes) + } + // 按面积算:540/1080 = 0.5,码率约为四分之一 + if half.VideoKbps > full.VideoKbps/3 { + t.Errorf("高度减半后码率应降到约四分之一,实际 %d → %d", full.VideoKbps, half.VideoKbps) + } +} + +// 源片没有音轨时别凭空加一份音频码率(拷贝模式拷不出音轨来) +func TestEstimateTranscodeNoAudioTrack(t *testing.T) { + // HasAudio 没置位 = 探测不到音轨 + p := transcodeProbe{Duration: 100, VideoKbps: 4000} + got := estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioCopy}, p, "libx264") + want := int64(2000 * 1000 / 8 * 100) + if got.Bytes != want { + t.Errorf("没有音轨时不该算音频:应 %d,实际 %d(音频 %d kbps)", want, got.Bytes, got.AudioKbps) + } + if got.AudioKbps != 0 { + t.Errorf("没有音轨时音频码率应为 0,实际 %d", got.AudioKbps) + } + // 显式要求重编码音频的话仍然按目标算(源片没有也只是多加一条空轨,不至于算错体积量级) + got = estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioAAC128}, p, "libx264") + if got.AudioKbps != 128 { + t.Errorf("显式重编码 128k 时应按 128 算,实际 %d", got.AudioKbps) + } +} + +// 探测不到视频码率时按「体积 ÷ 时长」倒推,不能算成 0(那会显示成「—」) +func TestVideoKbpsFallsBackToFileSize(t *testing.T) { + // 100 秒、50 MB 的文件 → 约 4000 kbps,扣掉 128k 音轨 + m := mediaInfo{duration: 100, hasAudio: true, audioKbps: 128} + got := videoKbpsOf(m, 50*1024*1024) + if got < 3000 || got > 4500 { + t.Errorf("倒推的源片码率应在 3000~4500 之间,实际 %d", got) + } +} + +// 容器总码率含音频,不能直接当视频码率:算体积时会和音频重复计一遍, +// 「跟随源片」时也会把音轨的码率算进视频里 +func TestVideoKbpsDropsAudioFromContainer(t *testing.T) { + m := mediaInfo{containerKbps: 3000, hasAudio: true, audioKbps: 192} + if got := videoKbpsOf(m, 0); got != 2808 { + t.Errorf("容器 3000 扣掉 192 音轨应为 2808,实际 %d", got) + } + // 视频流自己写了码率就以它为准,不再动 + m.videoKbps = 2600 + if got := videoKbpsOf(m, 0); got != 2600 { + t.Errorf("视频流自己写了码率就该用它,实际 %d", got) + } + // 没有音轨时不扣 + m = mediaInfo{containerKbps: 3000} + if got := videoKbpsOf(m, 0); got != 3000 { + t.Errorf("没有音轨时不扣,实际 %d", got) + } + // 音轨码率探测不到时按经验值扣,别一点不扣 + m = mediaInfo{containerKbps: 3000, hasAudio: true} + if got := videoKbpsOf(m, 0); got != 3000-defaultAudioKbps { + t.Errorf("音轨码率未知时应按 %d 扣,实际 %d", defaultAudioKbps, got) + } +} + +// 合并那边的自动码率一直按「视频流码率,没有就用容器总码率(含音频)」算, +// 改口径会让所有自动合并的成片都变小一截,这里锁住 +func TestStreamOrContainerKbpsKeepsOldBehavior(t *testing.T) { + if got := streamOrContainerKbps(mediaInfo{videoKbps: 2000, containerKbps: 2500}); got != 2000 { + t.Errorf("有视频流码率时应优先用它,实际 %d", got) + } + if got := streamOrContainerKbps(mediaInfo{containerKbps: 2500, hasAudio: true, audioKbps: 192}); got != 2500 { + t.Errorf("没有视频流码率时应退回容器总码率(含音频),实际 %d", got) + } +} + +// 探测结果的解析:容器总码率和视频流码率要分开记,封面图不能算成画面 +func TestParseMediaInfoSplitsBitrates(t *testing.T) { + out := "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'x.mp4':\n" + + " Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s\n" + + " Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 1234 kb/s, 30 fps, 30 tbr, 15360 tbn\n" + + " Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 192 kb/s\n" + m := parseMediaInfo(out) + if !m.hasVideo { + t.Error("有视频轨时 hasVideo 应为 true") + } + if m.videoKbps != 1234 || m.containerKbps != 1767 { + t.Errorf("视频流码率和容器总码率要分开记,实际 %d / %d", m.videoKbps, m.containerKbps) + } + if m.width != 1920 || m.height != 1080 || m.fps != 30 { + t.Errorf("画面参数没解析对:%dx%d %v fps", m.width, m.height, m.fps) + } + if !m.hasAudio || m.audioKbps != 192 { + t.Errorf("音轨没解析对:hasAudio=%v %d kbps", m.hasAudio, m.audioKbps) + } + if m.codec != "h264" { + t.Errorf("编码器名应为 h264,实际 %q", m.codec) + } + if d := m.duration; d < 175.0 || d > 175.2 { + t.Errorf("时长应为 175.07 秒,实际 %v", d) + } + + // 只有封面图(attached pic)的文件不算有视频轨,否则转码时会当成画面 + pic := " Duration: 00:00:10.00, start: 0.000000, bitrate: 128 kb/s\n" + + " Stream #0:0[0x1]: Video: mjpeg (attached pic), yuvj420p, 600x600, 90k tbr\n" + + " Stream #0:1: Audio: mp3, 44100 Hz, stereo, 128 kb/s\n" + pm := parseMediaInfo(pic) + if pm.hasVideo { + t.Error("封面图不该算视频轨") + } + if pm.width != 0 { + t.Errorf("封面图的分辨率不该当画面用,实际 %d", pm.width) + } +} + +// 同一个文件反复探测要走缓存(体积估算跟着输入框重发,每次起一个 ffmpeg 会把 CPU 占住), +// 但文件变了必须重新探 +func TestProbeMediaCacheInvalidatesOnChange(t *testing.T) { + ff := testFFmpeg(t) + setupMergeTest(t) + path := filepath.Join(recordSaveDir(), "缓存.mp4") + if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i", + "testsrc=size=160x120:rate=10:duration=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", path).CombinedOutput(); err != nil { + t.Skipf("造不出测试视频,跳过:%v %s", err, out) + } + + probeCache.Lock() + probeCache.m = nil + probeCache.Unlock() + + first := probeMediaCached(ff, path) + if first.duration <= 0 { + t.Fatalf("探测不到时长:%+v", first) + } + // 第二次必须命中缓存:键的数量不该变 + probeCache.Lock() + n1 := len(probeCache.m) + probeCache.Unlock() + if again := probeMediaCached(ff, path); again.duration != first.duration { + t.Errorf("命中缓存时结果应一致,实际 %v ≠ %v", again.duration, first.duration) + } + probeCache.Lock() + n2 := len(probeCache.m) + probeCache.Unlock() + if n1 != 1 || n2 != 1 { + t.Errorf("同一个文件第二次探测不该新增缓存项,实际 %d → %d", n1, n2) + } + + // 换个更长的片子放同一个路径:体积和修改时间都变了,必须重新探 + if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i", + "testsrc=size=160x120:rate=10:duration=3", "-c:v", "libx264", "-pix_fmt", "yuv420p", path).CombinedOutput(); err != nil { + t.Fatalf("重造测试视频失败:%v %s", err, out) + } + changed := probeMediaCached(ff, path) + if changed.duration <= first.duration+0.5 { + t.Errorf("文件换成 3 秒的了,时长该跟着变(%v → %v)", first.duration, changed.duration) + } +} + +// ==================== ffmpeg 参数 ==================== + +// 指定的编码器要被用上;没指定时按本机可用的挑 +func TestTranscodeArgsEncoder(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000} + + args, enc, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Encoder: "libx264"}, src) + if enc != "libx264" { + t.Fatalf("指定 libx264 就该用它,实际 %s", enc) + } + if !containsSeq(args, "-c:v", "libx264") { + t.Errorf("命令里没有 -c:v libx264:%v", args) + } + // -c:v 必须在 -i 之后,否则 ffmpeg 会当成输入选项 + iPos, cPos := indexOf(args, "in.mp4"), indexOf(args, "-c:v") + if iPos < 0 || cPos < 0 || cPos < iPos { + t.Errorf("-c:v 要放在 -i 之后,实际 %v", args) + } + + _, auto, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{}, src) + if auto == "" { + t.Fatal("自动模式也该挑出一个编码器") + } + if !containsSeq(args, "-map", "0:v:0") { + t.Errorf("应显式只映射第一条视频流(跳过封面图),实际 %v", args) + } +} + +// 质量模式下给质量参数、给目标码率模式下给码率参数,两者不能同时出现 +func TestTranscodeArgsRateControl(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000} + + args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Mode: rateModeBitrate, Bitrate: 1500}, src) + if !containsSeq(args, "-b:v", "1500k") { + t.Errorf("目标码率模式应给 -b:v 1500k,实际 %v", args) + } + if has(args, "-crf") || has(args, "-global_quality") { + t.Errorf("给了目标码率就不该再给质量参数:%v", args) + } + + // 质量模式没填数值时用默认值,不能把 0 塞给 ffmpeg + args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Mode: rateModeQuality, Encoder: "libx264"}, src) + if !containsSeq(args, "-crf", "23") { + t.Errorf("软编的质量模式应默认 CRF 23,实际 %v", args) + } +} + +// 缩放和帧率:只缩不放,帧率变了才加 fps 滤镜 +func TestTranscodeArgsFilters(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000} + + args, _, fps := transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Scale: 720, Fps: 60}, src) + vf := valueAfter(args, "-vf") + if !strings.Contains(vf, "scale=-2:720") { + t.Errorf("缩放应写进 -vf,实际 %q", vf) + } + if !strings.Contains(vf, "fps=60") { + t.Errorf("帧率变化应写进 -vf,实际 %q", vf) + } + if fps != 60 { + t.Errorf("返回的帧率应为 60,实际 %d", fps) + } + + // 帧率跟源片一样时不加滤镜,白跑一遍滤镜只会更慢 + _, _, fps = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Fps: 30}, src) + if fps != 0 { + t.Errorf("帧率没变时不该加 fps 滤镜,实际返回 %d", fps) + } + // 目标比源片高时也不放大 + _, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Scale: 2160}, src) + if vf := valueAfter(transcodeArgsArgs(ff, src), "-vf"); strings.Contains(vf, "scale") { + t.Errorf("不该把 1080p 放大到 2160p,实际 %q", vf) + } +} + +// HEVC 要带 hvc1 标签,否则 Apple 系打不开 +func TestTranscodeArgsHEVCTag(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000} + args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Encoder: "hevc_qsv", Mode: rateModeBitrate, Bitrate: 2000}, src) + if !containsSeq(args, "-tag:v", "hvc1") { + t.Errorf("HEVC 要写 hvc1,实际 %v", args) + } + args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Encoder: "libx264", Mode: rateModeBitrate, Bitrate: 2000}, src) + if has(args, "-tag:v") { + t.Errorf("H.264 不该加 hvc1,实际 %v", args) + } +} + +// 音频的三种处理要各自拼对 +func TestTranscodeArgsAudio(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1280, height: 720, fps: 30, videoKbps: 2000, hasAudio: true, audioKbps: 128} + + args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioNone}, src) + if !has(args, "-an") { + t.Errorf("去掉音轨应给 -an,实际 %v", args) + } + args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioAAC192}, src) + if !containsSeq(args, "-c:a", "aac") || !containsSeq(args, "-b:a", "192k") { + t.Errorf("AAC 192k 没拼对:%v", args) + } + args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioCopy}, src) + if !containsSeq(args, "-c:a", "copy") { + t.Errorf("直接拷贝应给 -c:a copy,实际 %v", args) + } +} + +// ==================== 端到端:真的转一个 ==================== + +// 拿 ffmpeg 造一段两秒的测试视频,再按各种参数真转一遍。 +// 只验证参数和落盘,体积大小不断言(不同机器编码结果不一样) +func TestRunTranscodeProducesFile(t *testing.T) { + ff := testFFmpeg(t) + setupMergeTest(t) + src := filepath.Join(recordSaveDir(), "源片.mp4") + if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i", + "testsrc=size=640x360:rate=30:duration=2", "-c:v", "libx264", "-pix_fmt", "yuv420p", src).CombinedOutput(); err != nil { + t.Skipf("造不出测试视频,跳过:%v %s", err, out) + } + + jobFinish("") + var got string + err := runMergeExclusive(func() error { + var e error + got, e = runTranscode(transcodeJobReq{ + Dir: filepath.Base(recordSaveDir()), Name: filepath.Base(src), + Options: transcodeOptions{Mode: rateModeBitrate, Bitrate: 800, Scale: 360, Preset: "veryfast"}, + }, mergeSaveDir(), 1, 1) + return e + }) + jobFinish(jobErrText(err)) + if err != nil { + t.Fatalf("转码失败:%v", err) + } + if got != "源片_转码.mp4" { + t.Errorf("返回的成片名不对:%q", got) + } + want := filepath.Join(mergeSaveDir(), "源片_转码.mp4") + if _, err := os.Stat(want); err != nil { + t.Fatalf("成片没落到 %s:%v", want, err) + } + // 转出来的文件不能是空壳 + if n := fileSizeOrZero(want); n < 1024 { + t.Errorf("成片只有 %d 字节,不正常", n) + } +} + +// 只有音轨的文件要当场挡住并说清原因,别等 ffmpeg 报一句「matches no streams」 +func TestRunTranscodeRejectsAudioOnly(t *testing.T) { + ff := testFFmpeg(t) + setupMergeTest(t) + src := filepath.Join(recordSaveDir(), "只有声音.mp4") + if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i", + "sine=frequency=440:duration=1", "-c:a", "aac", "-vn", src).CombinedOutput(); err != nil { + t.Skipf("造不出只有音轨的文件,跳过:%v %s", err, out) + } + + jobFinish("") + err := runMergeExclusive(func() error { + _, e := runTranscode(transcodeJobReq{ + Dir: filepath.Base(recordSaveDir()), Name: filepath.Base(src), + }, mergeSaveDir(), 1, 1) + return e + }) + jobFinish(jobErrText(err)) + if err == nil { + t.Fatal("只有音轨的文件不该转得下去") + } + if !strings.Contains(err.Error(), "没有视频轨") { + t.Errorf("报错要说明是没有视频轨,实际:%v", err) + } +} + +// ==================== 小工具 ==================== + +func has(args []string, v string) bool { + for _, a := range args { + if a == v { + return true + } + } + return false +} + +func indexOf(args []string, v string) int { + for i, a := range args { + if a == v { + return i + } + } + return -1 +} + +// 参数里是否连续出现了这两个值 +func containsSeq(args []string, a, b string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == a && args[i+1] == b { + return true + } + } + return false +} + +// 取 -vf 后面的值 +func valueAfter(args []string, key string) string { + for i, a := range args { + if a == key && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// 单独构造一次参数给上面的用例用(避免重复拼 src) +func transcodeArgsArgs(ff string, src mediaInfo) []string { + args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Scale: 2160}, src) + return args +} diff --git a/webui.go b/webui.go index 2414dc1..196ae1f 100644 --- a/webui.go +++ b/webui.go @@ -234,6 +234,27 @@ const webuiHTML = ` .vbar .vout{background:var(--accent)} .vlg{display:flex;gap:14px;font-size:11.5px;color:var(--sub);margin-top:4px} .vlg i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px} + /* 一个文件/一个任务一行:标题 + 参数网格 + 结果行 */ + .trow{border:1px solid var(--line);border-radius:var(--radius-s);padding:10px 12px;margin-bottom:10px;background:var(--soft2)} + .trow .th{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap} + .trow .tn{font-weight:600;font-size:13px;flex:1 1 200px;min-width:0;word-break:break-all} + .trow .tm{font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums} + .trow .tvid{flex:1 1 100%;font-size:11.5px;color:var(--sub);word-break:break-all;line-height:1.6} + .trow .tg{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px 10px;margin-top:9px} + .trow label{display:block;font-size:11px;color:var(--sub);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .trow select,.trow input[type=text]{width:100%;padding:5px 7px;font-size:12.5px} + .trow input.tn-num{text-align:center} + .trow .tr{margin-top:9px;font-size:12px;color:var(--sub);display:flex;gap:12px;flex-wrap:wrap;align-items:baseline} + .trow .tr b{color:var(--text);font-size:13px;font-variant-numeric:tabular-nums} + .trow .tbadge{font-size:11.5px;padding:2px 8px;border-radius:999px;background:var(--soft);color:var(--sub)} + .trow .tbadge.ok{background:var(--ok-soft);color:var(--ok)} + /* 失败用红字(--bad),别用主色:主色在深色主题下和「进行中」的灰看着差不多 */ + .trow .tbadge.bad{background:var(--accent-soft);color:var(--bad)} + /* 已经转完的那一行淡化一点,一眼看出还剩哪些没做 */ + .trow.done{opacity:.72} + @media (max-width:700px){ + .trow .tg{grid-template-columns:repeat(auto-fit,minmax(140px,1fr))} + } /* 速度折线:颜色走 CSS 变量,属性里写 var() 在部分浏览器不生效 */ .spark{display:block;width:100%;height:48px;margin-top:10px;border-radius:var(--radius-s);background:var(--soft2)} .spark polyline{fill:none;stroke:var(--accent);stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke} @@ -258,6 +279,7 @@ const webuiHTML = ` + @@ -387,36 +409,22 @@ const webuiHTML = `