Files
acfunlive-remake/transcode.go
T
tamakyi a409c5b4dd 新增「转码」分区与手动合并逐任务配置,修掉 PC 端图表文字被拉扁
一、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 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
  跳过无视频轨文件 / 估算失败提示 / 结果徽标)
2026-09-27 10:36:29 +08:00

1026 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// web UI 的转码:把录好的视频按自己的需要重新压一遍
// 合并是「录播 + 弹幕 → 成片」,转码是「已有视频 → 重新编码的视频」,
// 两者都要占同一个任务位(界面上的进度卡片只有一份),所以串行排队
package main
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"os"
"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) + `}`))
}