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
|
input.Quality = quality
|
||||||
t.maybeFillDuration(ctx, m, input)
|
t.maybeFillDuration(ctx, m, input)
|
||||||
settings := ffmpegArgSettingsFromConfig(cfg, quality)
|
settings := ffmpegArgSettingsFromConfig(cfg, quality)
|
||||||
|
// 「原画」档位遇到浏览器可解的 H.264 源时只换封装,避免慢机器上重编码
|
||||||
|
// 追不上播放(两核软件编码实测 0.1~0.2 倍速)。其它档位要改分辨率,仍重编码。
|
||||||
|
input.CopyVideo = t.remuxEligible(ctx, m, quality, subtitleStream)
|
||||||
jobEncoder := settings.encoder
|
jobEncoder := settings.encoder
|
||||||
if settings.noScale || subtitleStream >= 0 {
|
if settings.noScale || subtitleStream >= 0 {
|
||||||
jobEncoder = ""
|
jobEncoder = ""
|
||||||
@@ -312,6 +315,9 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
|||||||
if subtitleStream >= 0 {
|
if subtitleStream >= 0 {
|
||||||
jobEncoder = "software (subtitle burn-in)"
|
jobEncoder = "software (subtitle burn-in)"
|
||||||
}
|
}
|
||||||
|
if input.CopyVideo {
|
||||||
|
jobEncoder = remuxEncoderLabel
|
||||||
|
}
|
||||||
job := &hlsJob{
|
job := &hlsJob{
|
||||||
jobKey: jobKey,
|
jobKey: jobKey,
|
||||||
mediaID: mediaID,
|
mediaID: mediaID,
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ type transcodeInput struct {
|
|||||||
StartSec float64
|
StartSec float64
|
||||||
SubtitleStream *int
|
SubtitleStream *int
|
||||||
Quality *LocalHLSQuality
|
Quality *LocalHLSQuality
|
||||||
|
// CopyVideo 为 true 时视频流只换封装(-c:v copy),不重编码。
|
||||||
|
// 仅当源视频是浏览器可解的老三样(H.264/8bit/4:2:0)且不需要缩放时启用。
|
||||||
|
CopyVideo bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ffmpegArgSettings struct {
|
type ffmpegArgSettings struct {
|
||||||
@@ -52,6 +55,7 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist,
|
|||||||
// 原画档位不做硬件缩放的兼容处理,直接走软件编码,保持源分辨率。
|
// 原画档位不做硬件缩放的兼容处理,直接走软件编码,保持源分辨率。
|
||||||
settings.encoder = ""
|
settings.encoder = ""
|
||||||
}
|
}
|
||||||
|
copyVideo := input.CopyVideo && input.SubtitleStream == nil
|
||||||
video := ffmpegVideoPlanForSettings(settings)
|
video := ffmpegVideoPlanForSettings(settings)
|
||||||
if input.SubtitleStream != nil {
|
if input.SubtitleStream != nil {
|
||||||
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
|
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
|
||||||
@@ -60,13 +64,17 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist,
|
|||||||
settings.encoder = ""
|
settings.encoder = ""
|
||||||
video = ffmpegVideoPlanForSettings(settings)
|
video = ffmpegVideoPlanForSettings(settings)
|
||||||
}
|
}
|
||||||
|
if copyVideo {
|
||||||
|
// 直通:不做缩放、不挂滤镜、不选编码器,整条视频流原样塞进 TS。
|
||||||
|
video = ffmpegVideoPlan{codec: "copy"}
|
||||||
|
}
|
||||||
|
|
||||||
// Mid-file restarts must not use -re: output -ss would otherwise crawl to the
|
// 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.
|
// seek point at 1x wall-clock before emitting the first HLS segment.
|
||||||
realtime := settings.realtime && input.StartSec <= 0.05
|
realtime := settings.realtime && input.StartSec <= 0.05
|
||||||
args := baseFFmpegArgs(video.preInput, realtime)
|
args := baseFFmpegArgs(video.preInput, realtime)
|
||||||
args = appendInputAndVideoArgs(args, input, settings, video)
|
args = appendInputAndVideoArgs(args, input, settings, video, copyVideo)
|
||||||
args = appendOutputHLSArgs(args, settings, segments, playlist)
|
args = appendOutputHLSArgs(args, settings, segments, playlist, copyVideo)
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +169,7 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
|
|||||||
return args
|
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)...)
|
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||||
ss := ""
|
ss := ""
|
||||||
if input.StartSec > 0.05 {
|
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.
|
// scrubbing; CDNs with Range support jump via demuxer seek instead.
|
||||||
if ss != "" {
|
if ss != "" {
|
||||||
args = append(args, "-ss", ss)
|
args = append(args, "-ss", ss)
|
||||||
|
if copyVideo {
|
||||||
|
// 复制视频流时 ffmpeg 不能解码,精确 seek 会把关键帧之前的分组丢掉,
|
||||||
|
// 输出从 P 帧开始(花屏)。退到前一个关键帧,最多提前一两秒。
|
||||||
|
args = append(args, "-noaccurate_seek")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
args = append(args, "-i", input.Source)
|
args = append(args, "-i", input.Source)
|
||||||
if input.SubtitleStream != nil {
|
if input.SubtitleStream != nil {
|
||||||
@@ -197,7 +210,23 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
|
|||||||
return args
|
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,
|
return append(args,
|
||||||
"-pix_fmt", "yuv420p",
|
"-pix_fmt", "yuv420p",
|
||||||
"-b:v", settings.bitrate,
|
"-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