为什么
转码页的多选框只列 /api/files 的内容,也就是录播目录和成片目录两处;后端的
resolveTranscodeInput 走 resolveFile,也只认 record/merge 两个目录名。结果是
录播原片被合并且清掉之后(或者录播目录本来就空),转码页里只剩合并结果可挑,
磁盘上别处的视频一个都进不来。
怎么改
- transcodeJobReq 加 Path 字段,与 dir/name 二选一;新增 resolveTranscodeJob()
把「填了路径就按路径找(认绝对路径和从资源管理器复制来的带引号路径,走
resolveMergeInput)、否则回落 dir+name」收在一处,两条路最后统一过
mergeVideoExts 白名单 —— 路径这条能指向磁盘上任何文件,不挡的话手滑把 .jsonl
丢进来要等 ffmpeg 跑起来才报错。三个调用点和 runTranscode 全部改走它。
- transcodeProbe 回显绝对路径,页面拿它当任务身份:同名文件在不同目录里不会再
被认成一个,重复加入也因此挡得住。
- 转码卡片改成两栏:左边保留原来的文件列表多选,右边加「一行一个路径」的输入框
和「浏览…」按钮(复用已有的目录选择器,可多选、可跨文件夹跨盘攒,选完只追加不
覆盖)。openPick 多了个可选的第 5 参数用来指定起始目录,浏览转码文件时从成片
目录开始(录播目录可能是空的)。
- 报错前缀改用 transcodeJobReq.label():按路径进来时 Name 是空的,直接拼 j.Name
会变成「:参数不合法」。
验证
gofmt -l 干净;go vet 干净;Windows 与 GOOS=linux 编译均通过。
go test ./... 全绿;go test -race -count=1 ./... 27.2s 全绿;顶层用例 209 个
(含子测试 226),本次新增 3 个:-run 单独跑过 TestResolveTranscodeJobByPath /
TestResolveTranscodeJobRejectsBadPath / TestTranscodeProbeHandlerAcceptsPath。
反向验证 44/44:把 resolveTranscodeJob 的路径分支退回 resolveFile 后,上面两个
用例确实变红。
转码接口冒烟 40/40(新增 11 项按路径的:探测回显路径、估算、不存在 400、非视频
后缀 400、指向文件夹 400、真排队转出成片)。
页面 jsdom 冒烟 49/49 且无 JS 报错(新增 12 项:路径框存在、空框不给加入、手填能
进列表并带上绝对路径、探测请求按 path 发、重复加入不重复、浏览弹窗打开/追加不
覆盖/关闭/清空)。
1048 lines
35 KiB
Go
1048 lines
35 KiB
Go
// 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"` // 音频处理,空 = 直接拷贝
|
||
}
|
||
|
||
// 一个待转码的文件。
|
||
// 定位源文件有两种写法,二选一:Path 填了就用它(绝对路径、相对路径都行,可以是从资源管理器
|
||
// 复制来的带引号路径),没填才按 Dir + Name 在录播/成片文件夹里找
|
||
type transcodeJobReq struct {
|
||
Path string `json:"path"`
|
||
Dir string `json:"dir"` // record / merge
|
||
Name string `json:"name"` // 文件名
|
||
OutName string `json:"outName"` // 输出文件名,留空自动
|
||
Options transcodeOptions `json:"options"`
|
||
}
|
||
|
||
// 报错时给用户看的名字:按路径传进来的 Name 是空的,直接拼进消息会变成「:参数不合法」
|
||
func (j transcodeJobReq) label() string {
|
||
if p := strings.TrimSpace(j.Path); p != "" {
|
||
return filepath.Base(strings.Trim(p, `"'`))
|
||
}
|
||
return j.Name
|
||
}
|
||
|
||
// 探测到的源文件信息,页面用它显示原始参数和算预估体积
|
||
type transcodeProbe struct {
|
||
Name string `json:"name"`
|
||
Path string `json:"path"` // 源文件绝对路径,页面拿它当任务的身份(同名文件在不同目录不会认错)
|
||
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])
|
||
}
|
||
|
||
// 转码任务的输入:填了路径就按路径找(任意文件夹都行),否则按文件名在录播/成片文件夹里找。
|
||
// 两条路都要过一遍格式白名单 —— 路径这条能指向磁盘上任何文件,不挡的话
|
||
// 用户手滑把 .jsonl 弹幕丢进来,要等 ffmpeg 跑起来才报一句看不懂的错
|
||
func resolveTranscodeJob(j transcodeJobReq) (string, error) {
|
||
var (
|
||
full string
|
||
err error
|
||
)
|
||
if p := strings.TrimSpace(j.Path); p != "" {
|
||
full, err = resolveMergeInput(p)
|
||
} else {
|
||
full, err = resolveFile(j.Dir, j.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 := resolveTranscodeJob(job)
|
||
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 := resolveTranscodeJob(f)
|
||
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), Path: 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.label()+":"+msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
full, err := resolveTranscodeJob(j)
|
||
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.label()+":"+msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
full, err := resolveTranscodeJob(j)
|
||
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) + `}`))
|
||
}
|