diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index 13b9051..675d13b 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -29,7 +29,6 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "sync" "time" @@ -406,131 +405,6 @@ func hasFFmpegListEntry(output, name string) bool { return false } -// buildFFmpegArgs assembles the ffmpeg command line for the configured -// encoder. The function is package-level so the unit test can pin its -// behaviour without spawning a real ffmpeg process. -func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string { - enc := "" - if cfg.Transcoder.HardwareAccel { - enc = normalizedHardwareEncoder(cfg.Transcoder.Encoder) - } - bitrate := cfg.Transcoder.VideoBitrate - if bitrate == "" { - bitrate = "1500k" - } - maxrate := cfg.Transcoder.MaxRate - if maxrate == "" { - maxrate = "1800k" - } - bufsize := cfg.Transcoder.BufSize - if bufsize == "" { - bufsize = "3000k" - } - preset := cfg.Transcoder.Preset - if preset == "" { - preset = "veryfast" - } - height := cfg.Transcoder.MaxHeight - if height <= 0 { - height = 720 - } - segDur := cfg.Transcoder.SegmentSeconds - if segDur <= 0 { - segDur = 4 - } - - // Hardware-accel arguments differ in three places: - // - Optional input flags (-hwaccel + device init) - // - Optional input pixel-format upload filter - // - The actual -c:v encoder name + preset/quality flag - var pre, vf, vcodec, vpreset string - switch enc { - case "nvenc": - pre = "-hwaccel cuda -hwaccel_output_format cuda" - vf = fmt.Sprintf("scale_cuda=-2:min(%d\\,ih)", height) - vcodec = "h264_nvenc" - vpreset = "p4" - case "qsv": - pre = "-hwaccel qsv -hwaccel_output_format qsv" - vf = fmt.Sprintf("scale_qsv=-1:min(%d\\,ih)", height) - vcodec = "h264_qsv" - vpreset = preset - case "vaapi": - device := cfg.App.VAAPIDevice - if device == "" { - device = "/dev/dri/renderD128" - } - pre = fmt.Sprintf("-hwaccel vaapi -vaapi_device %s -hwaccel_output_format vaapi", device) - vf = fmt.Sprintf("scale_vaapi=-2:min(%d\\,ih),format=nv12|vaapi,hwupload", height) - vcodec = "h264_vaapi" - vpreset = "" - default: - // software - pre = "" - vf = fmt.Sprintf("scale=-2:min(%d\\,ih)", height) - vcodec = "libx264" - vpreset = preset - } - - args := []string{"-y", "-hide_banner", "-nostdin", "-fflags", "+genpts"} - for _, p := range splitNonEmptyArgs(pre) { - args = append(args, p) - } - if cfg.Transcoder.Realtime { - args = append(args, "-re") - } - args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", vf, "-c:v", vcodec) - if cfg.Transcoder.Threads > 0 && vcodec == "libx264" { - args = append(args, "-threads", strconv.Itoa(cfg.Transcoder.Threads)) - } - if vpreset != "" { - args = append(args, "-preset", vpreset) - } - args = append(args, - "-pix_fmt", "yuv420p", - "-b:v", bitrate, - "-maxrate", maxrate, - "-bufsize", bufsize, - "-c:a", "aac", - "-ar", "48000", - "-b:a", "128k", - "-ac", "2", - "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segDur), - "-f", "hls", - "-hls_time", fmt.Sprintf("%d", segDur), - "-hls_list_size", "0", - "-hls_segment_filename", segments, - playlist, - ) - return args -} - -// splitNonEmptyArgs is a tiny helper that mirrors strings.Fields for the -// pre-input flag string without dragging the strings import into the hot -// path of every call to buildFFmpegArgs. -func splitNonEmptyArgs(s string) []string { - if s == "" { - return nil - } - out := make([]string, 0, 4) - field := make([]rune, 0, 16) - flush := func() { - if len(field) > 0 { - out = append(out, string(field)) - field = field[:0] - } - } - for _, r := range s { - if r == ' ' || r == '\t' { - flush() - continue - } - field = append(field, r) - } - flush() - return out -} - // HumanFFmpegProfile is exposed for the admin UI / settings view. func (t *TranscoderService) HumanFFmpegProfile() string { return fmt.Sprintf("ffmpeg=%s, encoder=%s, output=%s", diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go new file mode 100644 index 0000000..30bb026 --- /dev/null +++ b/internal/service/transcoder_ffmpeg_args.go @@ -0,0 +1,166 @@ +package service + +import ( + "fmt" + "strconv" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +type ffmpegArgSettings struct { + encoder string + bitrate string + maxrate string + bufsize string + preset string + height int + segmentSeconds int + realtime bool + threads int + vaapiDevice string +} + +type ffmpegVideoPlan struct { + preInput string + filter string + codec string + preset string +} + +// buildFFmpegArgs assembles the ffmpeg command line for the configured +// encoder. The function is package-level so the unit test can pin its +// behaviour without spawning a real ffmpeg process. +func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string { + settings := ffmpegArgSettingsFromConfig(cfg) + video := ffmpegVideoPlanForSettings(settings) + + args := baseFFmpegArgs(video.preInput, settings.realtime) + args = appendInputAndVideoArgs(args, source, settings, video) + args = appendOutputHLSArgs(args, settings, segments, playlist) + return args +} + +func ffmpegArgSettingsFromConfig(cfg *config.Config) ffmpegArgSettings { + settings := ffmpegArgSettings{ + bitrate: ffmpegDefaultString(cfg.Transcoder.VideoBitrate, "1500k"), + maxrate: ffmpegDefaultString(cfg.Transcoder.MaxRate, "1800k"), + bufsize: ffmpegDefaultString(cfg.Transcoder.BufSize, "3000k"), + preset: ffmpegDefaultString(cfg.Transcoder.Preset, "veryfast"), + height: cfg.Transcoder.MaxHeight, + segmentSeconds: cfg.Transcoder.SegmentSeconds, + realtime: cfg.Transcoder.Realtime, + threads: cfg.Transcoder.Threads, + vaapiDevice: ffmpegDefaultString(cfg.App.VAAPIDevice, "/dev/dri/renderD128"), + } + if cfg.Transcoder.HardwareAccel { + settings.encoder = normalizedHardwareEncoder(cfg.Transcoder.Encoder) + } + if settings.height <= 0 { + settings.height = 720 + } + if settings.segmentSeconds <= 0 { + settings.segmentSeconds = 4 + } + return settings +} + +func ffmpegDefaultString(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func ffmpegVideoPlanForSettings(settings ffmpegArgSettings) ffmpegVideoPlan { + switch settings.encoder { + case "nvenc": + return ffmpegVideoPlan{ + preInput: "-hwaccel cuda -hwaccel_output_format cuda", + filter: fmt.Sprintf("scale_cuda=-2:min(%d\\,ih)", settings.height), + codec: "h264_nvenc", + preset: "p4", + } + case "qsv": + return ffmpegVideoPlan{ + preInput: "-hwaccel qsv -hwaccel_output_format qsv", + filter: fmt.Sprintf("scale_qsv=-1:min(%d\\,ih)", settings.height), + codec: "h264_qsv", + preset: settings.preset, + } + case "vaapi": + return ffmpegVideoPlan{ + preInput: fmt.Sprintf("-hwaccel vaapi -vaapi_device %s -hwaccel_output_format vaapi", settings.vaapiDevice), + filter: fmt.Sprintf("scale_vaapi=-2:min(%d\\,ih),format=nv12|vaapi,hwupload", settings.height), + codec: "h264_vaapi", + } + default: + return ffmpegVideoPlan{ + filter: fmt.Sprintf("scale=-2:min(%d\\,ih)", settings.height), + codec: "libx264", + preset: settings.preset, + } + } +} + +func baseFFmpegArgs(preInput string, realtime bool) []string { + args := []string{"-y", "-hide_banner", "-nostdin", "-fflags", "+genpts"} + args = append(args, splitNonEmptyArgs(preInput)...) + if realtime { + args = append(args, "-re") + } + return args +} + +func appendInputAndVideoArgs(args []string, source string, settings ffmpegArgSettings, video ffmpegVideoPlan) []string { + args = append(args, "-i", source, "-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)) + } + if video.preset != "" { + args = append(args, "-preset", video.preset) + } + return args +} + +func appendOutputHLSArgs(args []string, settings ffmpegArgSettings, segments, playlist string) []string { + return append(args, + "-pix_fmt", "yuv420p", + "-b:v", settings.bitrate, + "-maxrate", settings.maxrate, + "-bufsize", settings.bufsize, + "-c:a", "aac", + "-ar", "48000", + "-b:a", "128k", + "-ac", "2", + "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", settings.segmentSeconds), + "-f", "hls", + "-hls_time", fmt.Sprintf("%d", settings.segmentSeconds), + "-hls_list_size", "0", + "-hls_segment_filename", segments, + playlist, + ) +} + +// splitNonEmptyArgs mirrors the old whitespace split for pre-input flags. +func splitNonEmptyArgs(s string) []string { + if s == "" { + return nil + } + out := make([]string, 0, 4) + field := make([]rune, 0, 16) + flush := func() { + if len(field) > 0 { + out = append(out, string(field)) + field = field[:0] + } + } + for _, r := range s { + if r == ' ' || r == '\t' { + flush() + continue + } + field = append(field, r) + } + flush() + return out +}