mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -272,6 +272,9 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
input.Quality = quality
|
||||
t.maybeFillDuration(ctx, m, input)
|
||||
settings := ffmpegArgSettingsFromConfig(cfg, quality)
|
||||
// 「原画」档位遇到浏览器可解的 H.264 源时只换封装,避免慢机器上重编码
|
||||
// 追不上播放(两核软件编码实测 0.1~0.2 倍速)。其它档位要改分辨率,仍重编码。
|
||||
input.CopyVideo = t.remuxEligible(ctx, m, quality, subtitleStream)
|
||||
jobEncoder := settings.encoder
|
||||
if settings.noScale || subtitleStream >= 0 {
|
||||
jobEncoder = ""
|
||||
@@ -312,6 +315,9 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
if subtitleStream >= 0 {
|
||||
jobEncoder = "software (subtitle burn-in)"
|
||||
}
|
||||
if input.CopyVideo {
|
||||
jobEncoder = remuxEncoderLabel
|
||||
}
|
||||
job := &hlsJob{
|
||||
jobKey: jobKey,
|
||||
mediaID: mediaID,
|
||||
|
||||
@@ -16,6 +16,9 @@ type transcodeInput struct {
|
||||
StartSec float64
|
||||
SubtitleStream *int
|
||||
Quality *LocalHLSQuality
|
||||
// CopyVideo 为 true 时视频流只换封装(-c:v copy),不重编码。
|
||||
// 仅当源视频是浏览器可解的老三样(H.264/8bit/4:2:0)且不需要缩放时启用。
|
||||
CopyVideo bool
|
||||
}
|
||||
|
||||
type ffmpegArgSettings struct {
|
||||
@@ -52,6 +55,7 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist,
|
||||
// 原画档位不做硬件缩放的兼容处理,直接走软件编码,保持源分辨率。
|
||||
settings.encoder = ""
|
||||
}
|
||||
copyVideo := input.CopyVideo && input.SubtitleStream == nil
|
||||
video := ffmpegVideoPlanForSettings(settings)
|
||||
if input.SubtitleStream != nil {
|
||||
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
|
||||
@@ -60,13 +64,17 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist,
|
||||
settings.encoder = ""
|
||||
video = ffmpegVideoPlanForSettings(settings)
|
||||
}
|
||||
if copyVideo {
|
||||
// 直通:不做缩放、不挂滤镜、不选编码器,整条视频流原样塞进 TS。
|
||||
video = ffmpegVideoPlan{codec: "copy"}
|
||||
}
|
||||
|
||||
// Mid-file restarts must not use -re: output -ss would otherwise crawl to the
|
||||
// seek point at 1x wall-clock before emitting the first HLS segment.
|
||||
realtime := settings.realtime && input.StartSec <= 0.05
|
||||
args := baseFFmpegArgs(video.preInput, realtime)
|
||||
args = appendInputAndVideoArgs(args, input, settings, video)
|
||||
args = appendOutputHLSArgs(args, settings, segments, playlist)
|
||||
args = appendInputAndVideoArgs(args, input, settings, video, copyVideo)
|
||||
args = appendOutputHLSArgs(args, settings, segments, playlist, copyVideo)
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -161,7 +169,7 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
|
||||
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan, copyVideo bool) []string {
|
||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||
ss := ""
|
||||
if input.StartSec > 0.05 {
|
||||
@@ -172,6 +180,11 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
|
||||
// scrubbing; CDNs with Range support jump via demuxer seek instead.
|
||||
if ss != "" {
|
||||
args = append(args, "-ss", ss)
|
||||
if copyVideo {
|
||||
// 复制视频流时 ffmpeg 不能解码,精确 seek 会把关键帧之前的分组丢掉,
|
||||
// 输出从 P 帧开始(花屏)。退到前一个关键帧,最多提前一两秒。
|
||||
args = append(args, "-noaccurate_seek")
|
||||
}
|
||||
}
|
||||
args = append(args, "-i", input.Source)
|
||||
if input.SubtitleStream != nil {
|
||||
@@ -197,7 +210,23 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
|
||||
return args
|
||||
}
|
||||
|
||||
func appendOutputHLSArgs(args []string, settings ffmpegArgSettings, segments, playlist string) []string {
|
||||
func appendOutputHLSArgs(args []string, settings ffmpegArgSettings, segments, playlist string, copyVideo bool) []string {
|
||||
if copyVideo {
|
||||
// 视频是复制进来的,编码参数一律不适用;只保留音频转 AAC 与切片参数。
|
||||
// -avoid_negative_ts 让带 B 帧的源在 -ss 之后不会出现负时间戳。
|
||||
return append(args,
|
||||
"-avoid_negative_ts", "make_zero",
|
||||
"-c:a", "aac",
|
||||
"-ar", "48000",
|
||||
"-b:a", "128k",
|
||||
"-ac", "2",
|
||||
"-f", "hls",
|
||||
"-hls_time", fmt.Sprintf("%d", settings.segmentSeconds),
|
||||
"-hls_list_size", "0",
|
||||
"-hls_segment_filename", segments,
|
||||
playlist,
|
||||
)
|
||||
}
|
||||
return append(args,
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-b:v", settings.bitrate,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 蓝光原盘(m2ts,40 Mbps 级别)在两核小机器上重编码只有 0.1~0.2 倍速,HLS
|
||||
// 永远追不上播放,播放器等不到切片就报「转码失败」。当源视频本身就是浏览器
|
||||
// 能解的 H.264 / 8bit / 4:2:0,且用户选的是「原画」档位(不改分辨率)时,
|
||||
// 这里改走只换封装(-c:v copy)的直通路径:视频不重编码,音频转 AAC,
|
||||
// CPU 占用接近于零,起播时间从几十秒降到几秒。
|
||||
//
|
||||
// 直通只对「原画」生效。1080P/720P/480P 档位意味着要改分辨率,必须重新编码;
|
||||
// 那类请求仍然按原来的 libx264 路径处理(慢,但码率可控)。
|
||||
|
||||
// remuxEncoderLabel 是直通任务在日志与任务面板里的标签。
|
||||
const remuxEncoderLabel = "remux"
|
||||
|
||||
// remuxEligible 判断这次请求能否复制视频流而不是重编码。
|
||||
func (t *TranscoderService) remuxEligible(ctx context.Context, m *model.Media, quality *LocalHLSQuality, subtitleStream int) bool {
|
||||
if t == nil || t.repo == nil || m == nil {
|
||||
return false
|
||||
}
|
||||
// 烧录图形字幕要把字幕叠加进画面,必须解码+重编码。
|
||||
if subtitleStream >= 0 {
|
||||
return false
|
||||
}
|
||||
if !remuxAppliesToQuality(quality) {
|
||||
return false
|
||||
}
|
||||
probe, err := t.repo.MediaProbe.Get(ctx, m.ID)
|
||||
if err != nil || probe == nil {
|
||||
// 没有探测缓存就无法确认档次/像素格式,保守走重编码。
|
||||
return false
|
||||
}
|
||||
return remuxCopyableHeight(probe) > 0
|
||||
}
|
||||
|
||||
// remuxAppliesToQuality 只放行「原画」档位(Height 为 0,即不缩放)。
|
||||
// quality 为 nil 表示调用方没有指定档位(走全局 max_height),同样不直通,
|
||||
// 否则会绕过配置的分辨率上限给出一个远超预期的码流。
|
||||
func remuxAppliesToQuality(quality *LocalHLSQuality) bool {
|
||||
return quality != nil && quality.Height <= 0
|
||||
}
|
||||
|
||||
// remuxCopyableHeight 在源视频可直接复制进 HLS 时返回它的高度,否则返回 0。
|
||||
func remuxCopyableHeight(probe *model.MediaProbe) int {
|
||||
if probe == nil || probe.Height <= 0 {
|
||||
return 0
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(probe.VideoCodec), "h264") {
|
||||
return 0
|
||||
}
|
||||
if !browserSafeH264Payload(probe.Payload) {
|
||||
return 0
|
||||
}
|
||||
return probe.Height
|
||||
}
|
||||
|
||||
// probePayloadStreams 只解析落库 payload 里与直通判断有关的字段。
|
||||
type probePayloadStreams struct {
|
||||
Streams []struct {
|
||||
Type string `json:"type"`
|
||||
Profile string `json:"profile"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
// browserSafeH264Payload 只放行 8bit 4:2:0 的 Baseline/Main/High。
|
||||
// Hi10P(High 10)和 4:2:2 母带浏览器解不出来,复制过去只会黑屏或无声。
|
||||
func browserSafeH264Payload(payload string) bool {
|
||||
payload = strings.TrimSpace(payload)
|
||||
if payload == "" {
|
||||
// 老数据没有 payload:已经有 codec + 分辨率,按 8bit 常规情况处理。
|
||||
return true
|
||||
}
|
||||
var parsed probePayloadStreams
|
||||
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, stream := range parsed.Streams {
|
||||
if !strings.EqualFold(strings.TrimSpace(stream.Type), "video") {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(stream.Profile)) {
|
||||
case "", "constrained baseline", "baseline", "main", "high":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(stream.PixFmt)) {
|
||||
case "", "yuv420p", "yuvj420p":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 直通档位必须真的把视频流复制过去:一旦又带上 libx264 / -vf / 码率参数,
|
||||
// 慢机器上的「原画」就还是几十秒起播。
|
||||
func TestBuildFFmpegArgsRemuxCopiesVideoStream(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Transcoder.SegmentSeconds = 4
|
||||
cfg.Transcoder.Realtime = true
|
||||
cfg.Transcoder.Threads = 2
|
||||
cfg.Transcoder.MaxHeight = 720
|
||||
|
||||
quality, ok := LocalHLSQualityByID("source")
|
||||
if !ok {
|
||||
t.Fatal("source quality missing")
|
||||
}
|
||||
args := buildFFmpegArgsForInput(cfg, transcodeInput{
|
||||
Source: "https://cdn.example/x.m2ts",
|
||||
Headers: map[string]string{"User-Agent": "MeBox-115-GoClient/1.0"},
|
||||
StartSec: 52.4,
|
||||
Quality: &quality,
|
||||
CopyVideo: true,
|
||||
}, "/cache/hls/k/run-1/index.m3u8", "/cache/hls/k/run-1/seg_%05d.ts")
|
||||
joined := strings.Join(args, " ")
|
||||
|
||||
for _, want := range []string{
|
||||
"-c:v copy",
|
||||
"-c:a aac",
|
||||
"-ar 48000",
|
||||
"-b:a 128k",
|
||||
"-ac 2",
|
||||
"-avoid_negative_ts make_zero",
|
||||
"-ss 52.400",
|
||||
"-noaccurate_seek",
|
||||
"-hls_time 4",
|
||||
"-hls_segment_filename /cache/hls/k/run-1/seg_%05d.ts",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("remux args missing %q: %s", want, joined)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"libx264",
|
||||
"-vf ",
|
||||
"-b:v",
|
||||
"-maxrate",
|
||||
"-bufsize",
|
||||
"-force_key_frames",
|
||||
"-preset",
|
||||
"-threads",
|
||||
"-pix_fmt",
|
||||
} {
|
||||
if strings.Contains(joined, unwanted) {
|
||||
t.Errorf("remux args must not contain %q: %s", unwanted, joined)
|
||||
}
|
||||
}
|
||||
// 中途起播不能加 -re,否则会先按 1 倍速爬到目标位置才出第一个切片。
|
||||
if strings.Contains(joined, " -re ") {
|
||||
t.Errorf("mid-file remux must not use -re: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// 烧录图形字幕要把字幕叠进画面,必须解码重编码,不能被直通标志绕过。
|
||||
func TestBuildFFmpegArgsRemuxIgnoredForSubtitleBurnIn(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Transcoder.MaxHeight = 720
|
||||
cfg.Transcoder.SegmentSeconds = 4
|
||||
|
||||
quality, _ := LocalHLSQualityByID("source")
|
||||
subtitle := 4
|
||||
args := buildFFmpegArgsForInput(cfg, transcodeInput{
|
||||
Source: "/media/ep.mkv",
|
||||
Quality: &quality,
|
||||
CopyVideo: true,
|
||||
SubtitleStream: &subtitle,
|
||||
}, "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||
joined := strings.Join(args, " ")
|
||||
|
||||
if strings.Contains(joined, "-c:v copy") {
|
||||
t.Fatalf("subtitle burn-in must not copy the video stream: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "libx264") || !strings.Contains(joined, "-filter_complex") {
|
||||
t.Fatalf("subtitle burn-in should re-encode: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "-noaccurate_seek") {
|
||||
t.Fatalf("encoded path should keep accurate seek: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有「原画」档位(不缩放)可以直通;其它档位要改分辨率。
|
||||
func TestRemuxAppliesToQuality(t *testing.T) {
|
||||
if remuxAppliesToQuality(nil) {
|
||||
t.Fatal("nil quality must not remux: it falls back to the configured max height")
|
||||
}
|
||||
source, _ := LocalHLSQualityByID("source")
|
||||
if !remuxAppliesToQuality(&source) {
|
||||
t.Fatal("source quality should remux")
|
||||
}
|
||||
for _, id := range []string{"1080", "720", "480"} {
|
||||
quality, ok := LocalHLSQualityByID(id)
|
||||
if !ok {
|
||||
t.Fatalf("quality %s missing", id)
|
||||
}
|
||||
if remuxAppliesToQuality(&quality) {
|
||||
t.Fatalf("quality %s must re-encode", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有 8bit 4:2:0 的 H.264 能安全复制;Hi10P / 4:2:2 / HEVC 复制过去浏览器黑屏。
|
||||
func TestRemuxCopyableHeight(t *testing.T) {
|
||||
const blurayPayload = `{"container":"mpegts","duration_sec":1451,"bit_rate":42609934,"streams":[{"index":0,"type":"video","codec":"h264","profile":"High","width":1920,"height":1080,"pix_fmt":"yuv420p","frame_rate":"23.976"},{"index":1,"type":"audio","codec":"pcm_bluray","channels":2}]}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
probe *model.MediaProbe
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "bluray h264 8bit 420",
|
||||
probe: &model.MediaProbe{VideoCodec: "h264", Height: 1080, Payload: blurayPayload},
|
||||
want: 1080,
|
||||
},
|
||||
{
|
||||
name: "no payload falls back to codec and height",
|
||||
probe: &model.MediaProbe{VideoCodec: "h264", Height: 720},
|
||||
want: 720,
|
||||
},
|
||||
{
|
||||
name: "hevc cannot be copied into browser hls",
|
||||
probe: &model.MediaProbe{VideoCodec: "hevc", Height: 1080, Payload: `{"streams":[{"type":"video","profile":"Main 10","pix_fmt":"yuv420p10le"}]}`},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "hi10p h264 is unplayable in browsers",
|
||||
probe: &model.MediaProbe{VideoCodec: "h264", Height: 1080, Payload: `{"streams":[{"type":"video","profile":"High 10","pix_fmt":"yuv420p10le"}]}`},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "4:2:2 h264 is unplayable in browsers",
|
||||
probe: &model.MediaProbe{VideoCodec: "h264", Height: 1080, Payload: `{"streams":[{"type":"video","profile":"High 4:2:2","pix_fmt":"yuv422p"}]}`},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "missing probe",
|
||||
probe: nil,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "unknown height",
|
||||
probe: &model.MediaProbe{VideoCodec: "h264", Height: 0, Payload: blurayPayload},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := remuxCopyableHeight(tc.probe); got != tc.want {
|
||||
t.Fatalf("remuxCopyableHeight() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user