diff --git a/dist/mebox-linux-amd64 b/dist/mebox-linux-amd64 new file mode 100644 index 0000000..6dc6d87 Binary files /dev/null and b/dist/mebox-linux-amd64 differ diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index ff3f0e2..612314e 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -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, diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go index a76c132..60a3797 100644 --- a/internal/service/transcoder_ffmpeg_args.go +++ b/internal/service/transcoder_ffmpeg_args.go @@ -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, diff --git a/internal/service/transcoder_remux.go b/internal/service/transcoder_remux.go new file mode 100644 index 0000000..113979f --- /dev/null +++ b/internal/service/transcoder_remux.go @@ -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 +} diff --git a/internal/service/transcoder_remux_test.go b/internal/service/transcoder_remux_test.go new file mode 100644 index 0000000..c62c85a --- /dev/null +++ b/internal/service/transcoder_remux_test.go @@ -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) + } + }) + } +}