// 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 := checkDirField(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) + `}`)) }