mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
优化播放
This commit is contained in:
@@ -124,6 +124,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
||||
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
|
||||
@@ -25,7 +25,8 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
startSec := parseHLSStartSec(r)
|
||||
seekGen := parseHLSSeekGen(r)
|
||||
if _, err := s.transcoder.EnsureJobFrom(r.Context(), mediaID, startSec, seekGen); err != nil {
|
||||
subtitleStream := parseHLSSubtitleStream(r)
|
||||
if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil {
|
||||
return err
|
||||
}
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
@@ -60,6 +61,21 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseHLSSubtitleStream(r *http.Request) int {
|
||||
if r == nil {
|
||||
return -1
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("subtitle"))
|
||||
if raw == "" {
|
||||
return -1
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return -1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseHLSStartSec(r *http.Request) float64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
|
||||
+197
-13
@@ -16,12 +16,15 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,14 +32,16 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// SubtitleService is the discovery + conversion entry point.
|
||||
type SubtitleService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
||||
|
||||
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
|
||||
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
|
||||
@@ -61,11 +66,14 @@ func NewSubtitleService(cfg *config.Config, log *zap.Logger, repo *repository.Co
|
||||
|
||||
// SubtitleTrack describes one external subtitle file.
|
||||
type SubtitleTrack struct {
|
||||
Lang string `json:"lang"`
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
Codec string `json:"codec"`
|
||||
Lang string `json:"lang"`
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
Codec string `json:"codec"`
|
||||
Source string `json:"source"`
|
||||
Delivery string `json:"delivery"`
|
||||
StreamIndex int `json:"stream_index,omitempty"`
|
||||
}
|
||||
|
||||
// extToCodec maps the file extension to the inner codec name.
|
||||
@@ -86,7 +94,17 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
return s.discover(ctx, mediaID)
|
||||
tracks, err := s.discover(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SubtitleTrack, 0, len(tracks))
|
||||
for _, track := range tracks {
|
||||
if track.Source != "embedded" {
|
||||
out = append(out, track)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
@@ -187,16 +205,153 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
}
|
||||
lang := detectLang(fullName, matchedBase)
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: lang,
|
||||
Path: filepath.Join(c, e.Name()),
|
||||
Codec: codec,
|
||||
Lang: lang,
|
||||
Label: lang,
|
||||
Path: filepath.Join(c, e.Name()),
|
||||
Codec: codec,
|
||||
Source: "external",
|
||||
Delivery: "webvtt",
|
||||
})
|
||||
}
|
||||
}
|
||||
embedded, err := s.discoverEmbedded(ctx, m)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
tracks = append(tracks, embedded...)
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
type embeddedSubtitleProbe struct {
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Tags struct {
|
||||
Language string `json:"language"`
|
||||
Title string `json:"title"`
|
||||
} `json:"tags"`
|
||||
Disposition struct {
|
||||
Default int `json:"default"`
|
||||
Forced int `json:"forced"`
|
||||
} `json:"disposition"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
var imageSubtitleCodecs = map[string]bool{
|
||||
"hdmv_pgs_subtitle": true,
|
||||
"dvd_subtitle": true,
|
||||
"dvb_subtitle": true,
|
||||
"xsub": true,
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Media) ([]SubtitleTrack, error) {
|
||||
if s == nil || s.cfg == nil {
|
||||
return nil, errors.New("subtitle probe unavailable")
|
||||
}
|
||||
input, err := s.resolveInput(ctx, media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
args := []string{"-v", "error"}
|
||||
if headers := ffmpegHeaderText(input.Headers); headers != "" {
|
||||
args = append(args, "-headers", headers)
|
||||
}
|
||||
args = append(args,
|
||||
"-select_streams", "s",
|
||||
"-show_entries", "stream=index,codec_name:stream_tags=language,title:stream_disposition=default,forced",
|
||||
"-of", "json", input.Source,
|
||||
)
|
||||
out, err := exec.CommandContext(probeCtx, bin, args...).Output() // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var probe embeddedSubtitleProbe
|
||||
if err := json.Unmarshal(out, &probe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return subtitleTracksFromProbe(probe), nil
|
||||
}
|
||||
|
||||
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
||||
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
|
||||
for _, stream := range probe.Streams {
|
||||
codec := strings.ToLower(strings.TrimSpace(stream.CodecName))
|
||||
lang := strings.ToLower(strings.TrimSpace(stream.Tags.Language))
|
||||
if lang == "" {
|
||||
lang = "und"
|
||||
}
|
||||
label := strings.TrimSpace(stream.Tags.Title)
|
||||
if label == "" {
|
||||
label = lang
|
||||
}
|
||||
if stream.Disposition.Forced != 0 {
|
||||
label += "(强制)"
|
||||
} else if stream.Disposition.Default != 0 {
|
||||
label += "(默认)"
|
||||
}
|
||||
delivery := "webvtt"
|
||||
if imageSubtitleCodecs[codec] {
|
||||
delivery = "burn"
|
||||
}
|
||||
sourceLabel := "(内嵌)"
|
||||
if delivery == "burn" {
|
||||
sourceLabel = "(内嵌·图片)"
|
||||
}
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: label + sourceLabel,
|
||||
Path: "embedded:" + strconv.Itoa(stream.Index),
|
||||
Codec: codec,
|
||||
Source: "embedded",
|
||||
Delivery: delivery,
|
||||
StreamIndex: stream.Index,
|
||||
})
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
func (s *SubtitleService) SetStrmPlayTargetResolver(resolve func(context.Context, string) (*StrmPlayResult, error)) {
|
||||
if s != nil {
|
||||
s.strmResolve = resolve
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubtitleService) resolveInput(ctx context.Context, media *model.Media) (transcodeInput, error) {
|
||||
if media == nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
if !isStrmMediaRow(media) {
|
||||
if _, err := os.Stat(media.Path); err != nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
return transcodeInput{Source: media.Path}, nil
|
||||
}
|
||||
raw := strings.TrimSpace(media.STRMURL)
|
||||
if raw == "" && strings.HasSuffix(strings.ToLower(media.Path), ".strm") {
|
||||
raw, _ = readLocalSTRMTarget(media.Path)
|
||||
}
|
||||
if s.strmResolve != nil {
|
||||
resolved, err := s.strmResolve(ctx, raw)
|
||||
if err != nil {
|
||||
return transcodeInput{}, err
|
||||
}
|
||||
return transcodeInputFromPlayResult(resolved)
|
||||
}
|
||||
if isHTTPPlaybackTarget(raw) {
|
||||
return transcodeInput{Source: raw}, nil
|
||||
}
|
||||
return transcodeInput{}, errors.New("subtitle source unavailable")
|
||||
}
|
||||
|
||||
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
|
||||
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
|
||||
|
||||
@@ -220,6 +375,13 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if strings.HasPrefix(sub, "embedded:") {
|
||||
index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:"))
|
||||
if err != nil || index < 0 {
|
||||
return errors.New("invalid embedded subtitle")
|
||||
}
|
||||
return s.serveEmbedded(ctx, m, index, w)
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -252,6 +414,28 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SubtitleService) serveEmbedded(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
|
||||
input, err := s.resolveInput(ctx, media)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := []string{"-hide_banner", "-loglevel", "error"}
|
||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||
args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-f", "webvtt", "-")
|
||||
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
|
||||
cmd.Stdout = w
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("extract embedded subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeRaw writes the subtitle file in its original format without any
|
||||
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
|
||||
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -46,6 +47,27 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||
var probe embeddedSubtitleProbe
|
||||
raw := []byte(`{"streams":[
|
||||
{"index":2,"codec_name":"ass","tags":{"language":"chi","title":"中文"},"disposition":{"default":1}},
|
||||
{"index":4,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"eng"},"disposition":{"forced":1}}
|
||||
]}`)
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracks := subtitleTracksFromProbe(probe)
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
|
||||
}
|
||||
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
|
||||
t.Fatalf("text track = %#v", tracks[0])
|
||||
}
|
||||
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
|
||||
t.Fatalf("bitmap track = %#v", tracks[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseTimecode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"0:00:01": "00:00:01",
|
||||
|
||||
@@ -71,6 +71,9 @@ type hlsJob struct {
|
||||
// seekGen is the client `_seek` token for this job. Newer gens win;
|
||||
// older/missing gens must not cancel a mid-file restart.
|
||||
seekGen int64
|
||||
// subtitleStream is an absolute ffmpeg stream index to burn into the
|
||||
// picture, or -1 when no bitmap subtitle is selected.
|
||||
subtitleStream int
|
||||
// done is closed when the ffmpeg goroutine fully exits (after process death).
|
||||
done chan struct{}
|
||||
}
|
||||
@@ -121,6 +124,12 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
// in-flight playlist refreshes from a destroyed player commonly arrive as
|
||||
// start=0 right after a scrub and would otherwise reset playback to the head.
|
||||
func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64, seekGen int64) (string, error) {
|
||||
return t.EnsureJobFromSubtitle(ctx, mediaID, startSec, seekGen, -1)
|
||||
}
|
||||
|
||||
// EnsureJobFromSubtitle is EnsureJobFrom with an optional bitmap subtitle
|
||||
// stream. Selecting or closing a burned subtitle creates a new HLS generation.
|
||||
func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int) (string, error) {
|
||||
if !t.cfg.Transcoder.Enabled {
|
||||
return "", ErrTranscodeDisabled
|
||||
}
|
||||
@@ -141,12 +150,12 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSStart(existing.startSec, startSec) {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
|
||||
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
@@ -163,6 +172,9 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
return "", err
|
||||
}
|
||||
input.StartSec = startSec
|
||||
if subtitleStream >= 0 {
|
||||
input.SubtitleStream = &subtitleStream
|
||||
}
|
||||
t.maybeFillDuration(ctx, m, input)
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
return "", err
|
||||
@@ -177,12 +189,12 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSStart(existing.startSec, startSec) {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
|
||||
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
@@ -198,16 +210,21 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s
|
||||
}
|
||||
|
||||
jobCtx, cancel := context.WithCancel(context.Background())
|
||||
jobEncoder := t.effectiveEncoder()
|
||||
if subtitleStream >= 0 {
|
||||
jobEncoder = "software (subtitle burn-in)"
|
||||
}
|
||||
job := &hlsJob{
|
||||
mediaID: mediaID,
|
||||
outputDir: outDir,
|
||||
cancel: cancel,
|
||||
startedAt: time.Now(),
|
||||
lastAccess: time.Now(),
|
||||
encoder: t.effectiveEncoder(),
|
||||
startSec: startSec,
|
||||
seekGen: seekGen,
|
||||
done: make(chan struct{}),
|
||||
mediaID: mediaID,
|
||||
outputDir: outDir,
|
||||
cancel: cancel,
|
||||
startedAt: time.Now(),
|
||||
lastAccess: time.Now(),
|
||||
encoder: jobEncoder,
|
||||
startSec: startSec,
|
||||
seekGen: seekGen,
|
||||
subtitleStream: subtitleStream,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
t.jobs[mediaID] = job
|
||||
t.mu.Unlock()
|
||||
@@ -229,6 +246,20 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool
|
||||
if sameHLSStart(existing.startSec, startSec) {
|
||||
return false
|
||||
}
|
||||
return newerHLSGenerationMayReplace(existing, seekGen)
|
||||
}
|
||||
|
||||
func shouldReplaceHLSJobConfiguration(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int) bool {
|
||||
if existing == nil {
|
||||
return true
|
||||
}
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream) {
|
||||
return false
|
||||
}
|
||||
return newerHLSGenerationMayReplace(existing, seekGen)
|
||||
}
|
||||
|
||||
func newerHLSGenerationMayReplace(existing *hlsJob, seekGen int64) bool {
|
||||
if seekGen > 0 && existing.seekGen > 0 && seekGen < existing.seekGen {
|
||||
return false
|
||||
}
|
||||
@@ -239,6 +270,12 @@ func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool
|
||||
return true
|
||||
}
|
||||
|
||||
func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int) bool {
|
||||
return existing != nil &&
|
||||
existing.subtitleStream == subtitleStream &&
|
||||
sameHLSStart(existing.startSec, startSec)
|
||||
}
|
||||
|
||||
func (t *TranscoderService) mediaStartGate(mediaID string) *sync.Mutex {
|
||||
v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{})
|
||||
return v.(*sync.Mutex)
|
||||
|
||||
@@ -11,9 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type transcodeInput struct {
|
||||
Source string
|
||||
Headers map[string]string
|
||||
StartSec float64
|
||||
Source string
|
||||
Headers map[string]string
|
||||
StartSec float64
|
||||
SubtitleStream *int
|
||||
}
|
||||
|
||||
type ffmpegArgSettings struct {
|
||||
@@ -46,6 +47,13 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st
|
||||
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
|
||||
settings := ffmpegArgSettingsFromConfig(cfg)
|
||||
video := ffmpegVideoPlanForSettings(settings)
|
||||
if input.SubtitleStream != nil {
|
||||
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
|
||||
// VAAPI frames here would require a download/upload filter chain that
|
||||
// differs by driver and is considerably less portable.
|
||||
settings.encoder = ""
|
||||
video = ffmpegVideoPlanForSettings(settings)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -140,7 +148,16 @@ func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpe
|
||||
args = append(args, "-ss", ss)
|
||||
}
|
||||
args = append(args, "-i", input.Source)
|
||||
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
if input.SubtitleStream != nil {
|
||||
filter := fmt.Sprintf(
|
||||
"[0:v:0][0:%d]overlay=0:0:eof_action=pass,scale=-2:min(%d\\,ih)[v]",
|
||||
*input.SubtitleStream,
|
||||
settings.height,
|
||||
)
|
||||
args = append(args, "-filter_complex", filter, "-map", "[v]", "-map", "0:a:0?", "-c:v", video.codec)
|
||||
} else {
|
||||
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
}
|
||||
if settings.threads > 0 && video.codec == "libx264" {
|
||||
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
||||
}
|
||||
|
||||
@@ -255,6 +255,29 @@ func TestBuildFFmpegArgsHTTPSeekAfterDashI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsBurnsBitmapSubtitleInSoftware(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Transcoder.HardwareAccel = true
|
||||
cfg.Transcoder.Encoder = "nvenc"
|
||||
cfg.Transcoder.MaxHeight = 720
|
||||
cfg.Transcoder.SegmentSeconds = 4
|
||||
stream := 3
|
||||
args := buildFFmpegArgsForInput(cfg, transcodeInput{
|
||||
Source: "/x.mkv",
|
||||
SubtitleStream: &stream,
|
||||
}, "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "[0:v:0][0:3]overlay=0:0:eof_action=pass") {
|
||||
t.Fatalf("bitmap subtitle overlay missing: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-map [v]") || !strings.Contains(joined, "-c:v libx264") {
|
||||
t.Fatalf("burn-in should use the filtered software video stream: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "cuda") || strings.Contains(joined, "h264_nvenc") {
|
||||
t.Fatalf("burn-in must not retain hardware-only frames: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameHLSStart(t *testing.T) {
|
||||
if !sameHLSStart(10, 10.2) {
|
||||
t.Fatal("expected close starts to match")
|
||||
@@ -283,6 +306,19 @@ func TestShouldReplaceHLSJob(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitmapSubtitleChangeReplacesHLSJob(t *testing.T) {
|
||||
existing := &hlsJob{startSec: 120, seekGen: 1000, subtitleStream: 2}
|
||||
if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, 3) {
|
||||
t.Fatal("changing bitmap subtitle must replace the HLS generation")
|
||||
}
|
||||
if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, -1) {
|
||||
t.Fatal("closing bitmap subtitle must replace the HLS generation")
|
||||
}
|
||||
if shouldReplaceHLSJobConfiguration(existing, 120.2, 1001, 2) {
|
||||
t.Fatal("same subtitle and nearby start should reuse the HLS generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterHLSSegmentQueryDropsStart(t *testing.T) {
|
||||
got := filterHLSSegmentQuery("token=abc&start=120.5&_seek=1001&profile_id=1")
|
||||
if strings.Contains(got, "start=") {
|
||||
|
||||
Reference in New Issue
Block a user