diff --git a/build/admin/bundleAdmin.sh b/build/admin/bundleAdmin.sh index ba01fcefa..f215cb68b 100755 --- a/build/admin/bundleAdmin.sh +++ b/build/admin/bundleAdmin.sh @@ -18,6 +18,7 @@ trap shutdown INT TERM ABRT EXIT echo "Cloning owncast admin into $INSTALL_TEMP_DIRECTORY..." git clone https://github.com/owncast/owncast-admin 2> /dev/null cd owncast-admin +git checkout gek/codec-selection echo "Installing npm modules for the owncast admin..." npm --silent install 2> /dev/null diff --git a/config/defaults.go b/config/defaults.go index bb5fc5635..0570ef604 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -53,8 +53,8 @@ func GetDefaults() Defaults { { IsAudioPassthrough: true, VideoBitrate: 1200, - EncoderPreset: "veryfast", Framerate: 24, + CPUUsageLevel: 2, }, }, } diff --git a/controllers/admin/config.go b/controllers/admin/config.go index 4aff48704..a57fc5cb1 100644 --- a/controllers/admin/config.go +++ b/controllers/admin/config.go @@ -438,26 +438,6 @@ func SetStreamOutputVariants(w http.ResponseWriter, r *http.Request) { return } - // Temporary: Convert the cpuUsageLevel to a preset. In the future we will have - // different codec models that will handle this for us and we won't - // be keeping track of presets at all. But for now... - presetMapping := []string{ - "ultrafast", - "superfast", - "veryfast", - "faster", - "fast", - } - - for i, variant := range videoVariants.Value { - preset := "superfast" - if variant.CPUUsageLevel > 0 && variant.CPUUsageLevel <= len(presetMapping) { - preset = presetMapping[variant.CPUUsageLevel-1] - } - variant.EncoderPreset = preset - videoVariants.Value[i] = variant - } - if err := data.SetStreamOutputVariants(videoVariants.Value); err != nil { controllers.WriteSimpleResponse(w, false, "unable to update video config with provided values "+err.Error()) return @@ -508,6 +488,26 @@ func SetChatDisabled(w http.ResponseWriter, r *http.Request) { controllers.WriteSimpleResponse(w, true, "chat disabled status updated") } +// SetVideoCodec will change the codec used for video encoding. +func SetVideoCodec(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + controllers.WriteSimpleResponse(w, false, "unable to change video codec") + return + } + + if err := data.SetVideoCodec(configValue.Value.(string)); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update codec") + return + } + + controllers.WriteSimpleResponse(w, true, "video codec updated") +} + // SetExternalActions will set the 3rd party actions for the web interface. func SetExternalActions(w http.ResponseWriter, r *http.Request) { type externalActionsRequest struct { diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index 042d41a39..9ac37d458 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -6,6 +6,7 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/core/transcoder" "github.com/owncast/owncast/models" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" @@ -13,6 +14,8 @@ import ( // GetServerConfig gets the config details of the server. func GetServerConfig(w http.ResponseWriter, r *http.Request) { + ffmpeg := utils.ValidatedFfmpegPath(data.GetFfMpegPath()) + var videoQualityVariants = make([]models.StreamOutputVariant, 0) for _, variant := range data.GetStreamOutputVariants() { videoQualityVariants = append(videoQualityVariants, models.StreamOutputVariant{ @@ -20,10 +23,9 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { IsAudioPassthrough: variant.GetIsAudioPassthrough(), IsVideoPassthrough: variant.IsVideoPassthrough, Framerate: variant.GetFramerate(), - EncoderPreset: variant.GetEncoderPreset(), VideoBitrate: variant.VideoBitrate, AudioBitrate: variant.AudioBitrate, - CPUUsageLevel: variant.GetCPUUsageLevel(), + CPUUsageLevel: variant.CPUUsageLevel, ScaledWidth: variant.ScaledWidth, ScaledHeight: variant.ScaledHeight, }) @@ -41,7 +43,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { NSFW: data.GetNSFW(), CustomStyles: data.GetCustomStyles(), }, - FFmpegPath: utils.ValidatedFfmpegPath(data.GetFfMpegPath()), + FFmpegPath: ffmpeg, StreamKey: data.GetStreamKey(), WebServerPort: config.WebServerPort, RTMPServerPort: data.GetRTMPPortNumber(), @@ -56,6 +58,8 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { }, S3: data.GetS3Config(), ExternalActions: data.GetExternalActions(), + SupportedCodecs: transcoder.GetCodecs(ffmpeg), + VideoCodec: data.GetVideoCodec(), } w.Header().Set("Content-Type", "application/json") @@ -77,6 +81,8 @@ type serverConfigAdminResponse struct { YP yp `json:"yp"` ChatDisabled bool `json:"chatDisabled"` ExternalActions []models.ExternalAction `json:"externalActions"` + SupportedCodecs []string `json:"supportedCodecs"` + VideoCodec string `json:"videoCodec"` } type videoSettings struct { diff --git a/core/core.go b/core/core.go index 3d3010b9c..953a9cdbb 100644 --- a/core/core.go +++ b/core/core.go @@ -99,6 +99,7 @@ func transitionToOfflineVideoStreamContent() { offlineFilePath := "static/" + offlineFilename _transcoder := transcoder.NewTranscoder() _transcoder.SetInput(offlineFilePath) + _transcoder.SetIdentifier("offline") _transcoder.Start() // Copy the logo to be the thumbnail diff --git a/core/data/config.go b/core/data/config.go index d1f4d2b9b..40f6680e7 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -39,6 +39,7 @@ const videoStreamOutputVariantsKey = "video_stream_output_variants" const chatDisabledKey = "chat_disabled" const externalActionsKey = "external_actions" const customStylesKey = "custom_styles" +const videoCodecKey = "video_codec" // GetExtraPageBodyContent will return the user-supplied body content. func GetExtraPageBodyContent() string { @@ -481,6 +482,20 @@ func GetCustomStyles() string { return style } +// SetVideoCodec will set the codec used for video encoding. +func SetVideoCodec(codec string) error { + return _datastore.SetString(videoCodecKey, codec) +} + +func GetVideoCodec() string { + codec, err := _datastore.GetString(videoCodecKey) + if codec == "" || err != nil { + return "libx264" // Default value + } + + return codec +} + // VerifySettings will perform a sanity check for specific settings values. func VerifySettings() error { if GetStreamKey() == "" { diff --git a/core/transcoder/codecs.go b/core/transcoder/codecs.go new file mode 100644 index 000000000..dcfa6a50a --- /dev/null +++ b/core/transcoder/codecs.go @@ -0,0 +1,373 @@ +//nolint:goconst + +package transcoder + +import ( + "fmt" + "os/exec" + "strings" + + log "github.com/sirupsen/logrus" +) + +// Codec represents a supported codec on the system. +type Codec interface { + Name() string + DisplayName() string + GlobalFlags() string + PixelFormat() string + ExtraArguments() string + ExtraFilters() string + VariantFlags(v *HLSVariant) string + GetPresetForLevel(l int) string +} + +var supportedCodecs = map[string]string{ + (&Libx264Codec{}).Name(): "libx264", + (&OmxCodec{}).Name(): "omx", + (&VaapiCodec{}).Name(): "vaapi", + (&NvencCodec{}).Name(): "NVIDIA nvenc", +} + +type Libx264Codec struct { +} + +func (c *Libx264Codec) Name() string { + return "libx264" +} + +func (c *Libx264Codec) DisplayName() string { + return "x264" +} + +func (c *Libx264Codec) GlobalFlags() string { + return "" +} + +func (c *Libx264Codec) PixelFormat() string { + return "yuv420p" +} + +func (c *Libx264Codec) ExtraArguments() string { + return strings.Join([]string{ + "-tune", "zerolatency", // Option used for good for fast encoding and low-latency streaming (always includes iframes in each segment) + }, " ") +} + +func (c *Libx264Codec) ExtraFilters() string { + return "" +} + +func (c *Libx264Codec) VariantFlags(v *HLSVariant) string { + bufferSize := int(float64(v.videoBitrate) * 1.2) // How often it checks the bitrate of encoded segments to see if it's too high/low. + + return strings.Join([]string{ + fmt.Sprintf("-x264-params:v:%d \"scenecut=0:open_gop=0\"", v.index), // How often the encoder checks the bitrate in order to meet average/max values + fmt.Sprintf("-bufsize:v:%d %dk", v.index, bufferSize), + fmt.Sprintf("-profile:v:%d %s", v.index, "high"), // Encoding profile + }, " ") +} + +func (c *Libx264Codec) GetPresetForLevel(l int) string { + presetMapping := []string{ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + } + + if l >= len(presetMapping) { + return "superfast" + } + + return presetMapping[l] +} + +type OmxCodec struct { +} + +func (c *OmxCodec) Name() string { + return "h264_omx" +} + +func (c *OmxCodec) DisplayName() string { + return "OpenMAX (omx)" +} + +func (c *OmxCodec) GlobalFlags() string { + return "" +} + +func (c *OmxCodec) PixelFormat() string { + return "yuv420p" +} + +func (c *OmxCodec) ExtraArguments() string { + return strings.Join([]string{ + "-tune", "zerolatency", // Option used for good for fast encoding and low-latency streaming (always includes iframes in each segment) + }, " ") +} + +func (c *OmxCodec) ExtraFilters() string { + return "" +} + +func (c *OmxCodec) VariantFlags(v *HLSVariant) string { + return "" +} + +func (c *OmxCodec) GetPresetForLevel(l int) string { + presetMapping := []string{ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + } + + if l >= len(presetMapping) { + return "superfast" + } + + return presetMapping[l] +} + +type VaapiCodec struct { +} + +func (c *VaapiCodec) Name() string { + return "h264_vaapi" +} + +func (c *VaapiCodec) DisplayName() string { + return "VA-API" +} + +func (c *VaapiCodec) GlobalFlags() string { + flags := []string{ + "-vaapi_device", "/dev/dri/renderD128", + } + + return strings.Join(flags, " ") +} + +func (c *VaapiCodec) PixelFormat() string { + return "vaapi_vld" +} + +func (c *VaapiCodec) ExtraFilters() string { + return "format=nv12,hwupload" +} + +func (c *VaapiCodec) ExtraArguments() string { + return "" +} + +func (c *VaapiCodec) VariantFlags(v *HLSVariant) string { + return "" +} + +func (c *VaapiCodec) GetPresetForLevel(l int) string { + presetMapping := []string{ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + } + + if l >= len(presetMapping) { + return "superfast" + } + + return presetMapping[l] +} + +type NvencCodec struct { +} + +func (c *NvencCodec) Name() string { + return "h264_nvenc" +} + +func (c *NvencCodec) DisplayName() string { + return "nvidia nvenc" +} + +func (c *NvencCodec) GlobalFlags() string { + flags := []string{ + "-hwaccel cuda", + } + + return strings.Join(flags, " ") +} + +func (c *NvencCodec) PixelFormat() string { + return "yuv420p" +} + +func (c *NvencCodec) ExtraArguments() string { + return "" +} + +func (c *NvencCodec) ExtraFilters() string { + return "" +} + +func (c *NvencCodec) VariantFlags(v *HLSVariant) string { + tuning := "ll" // low latency + return fmt.Sprintf("-tune:v:%d %s", v.index, tuning) +} + +func (c *NvencCodec) GetPresetForLevel(l int) string { + presetMapping := []string{ + "p1", + "p2", + "p3", + "p4", + "p5", + } + + if l >= len(presetMapping) { + return "p3" + } + + return presetMapping[l] +} + +type QuicksyncCodec struct { +} + +func (c *QuicksyncCodec) Name() string { + return "h264_qsv" +} + +func (c *QuicksyncCodec) DisplayName() string { + return "Intel QuickSync" +} + +func (c *QuicksyncCodec) GlobalFlags() string { + return "" +} + +func (c *QuicksyncCodec) PixelFormat() string { + return "nv12" +} + +func (c *QuicksyncCodec) ExtraArguments() string { + return "" +} + +func (c *QuicksyncCodec) ExtraFilters() string { + return "" +} + +func (c *QuicksyncCodec) VariantFlags(v *HLSVariant) string { + return "" +} + +func (c *QuicksyncCodec) GetPresetForLevel(l int) string { + presetMapping := []string{ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + } + + if l >= len(presetMapping) { + return "superfast" + } + + return presetMapping[l] +} + +type Video4Linux struct{} + +func (c *Video4Linux) Name() string { + return "h264_v4l2m2m" +} + +func (c *Video4Linux) DisplayName() string { + return "Video4Linux" +} + +func (c *Video4Linux) GlobalFlags() string { + return "" +} + +func (c *Video4Linux) PixelFormat() string { + return "nv21" +} + +func (c *Video4Linux) ExtraArguments() string { + return "" +} + +func (c *Video4Linux) ExtraFilters() string { + return "" +} + +func (c *Video4Linux) VariantFlags(v *HLSVariant) string { + return "" +} + +func (c *Video4Linux) GetPresetForLevel(l int) string { + presetMapping := []string{ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + } + + if l >= len(presetMapping) { + return "superfast" + } + + return presetMapping[l] +} + +// GetCodecs will return the supported codecs available on the system. +func GetCodecs(ffmpegPath string) []string { + codecs := make([]string, 0) + + cmd := exec.Command(ffmpegPath, "-encoders") + out, err := cmd.CombinedOutput() + if err != nil { + log.Errorln(err) + return codecs + } + + response := string(out) + lines := strings.Split(response, "\n") + for _, line := range lines { + if strings.Contains(line, "H.264") { + fields := strings.Fields(line) + codec := fields[1] + if _, supported := supportedCodecs[codec]; supported { + codecs = append(codecs, codec) + } + } + } + + return codecs +} + +func getCodec(name string) Codec { + switch name { + case (&NvencCodec{}).Name(): + return &NvencCodec{} + case (&VaapiCodec{}).Name(): + return &VaapiCodec{} + case (&QuicksyncCodec{}).Name(): + return &QuicksyncCodec{} + case (&OmxCodec{}).Name(): + return &OmxCodec{} + case (&Video4Linux{}).Name(): + return &Video4Linux{} + default: + return &Libx264Codec{} + } +} diff --git a/core/transcoder/fileWriterReceiverService.go b/core/transcoder/fileWriterReceiverService.go index b12941dde..c6b2059a2 100644 --- a/core/transcoder/fileWriterReceiverService.go +++ b/core/transcoder/fileWriterReceiverService.go @@ -62,6 +62,8 @@ func (s *FileWriterReceiverService) uploadHandler(w http.ResponseWriter, r *http writePath := filepath.Join(config.PrivateHLSStoragePath, path) var buf bytes.Buffer + defer r.Body.Close() + _, _ = io.Copy(&buf, r.Body) data := buf.Bytes() diff --git a/core/transcoder/transcoder.go b/core/transcoder/transcoder.go index 57cc457d9..f55607395 100644 --- a/core/transcoder/transcoder.go +++ b/core/transcoder/transcoder.go @@ -1,6 +1,7 @@ package transcoder import ( + "bufio" "fmt" "os/exec" "strconv" @@ -27,6 +28,7 @@ type Transcoder struct { ffmpegPath string segmentIdentifier string internalListenerPort string + codec Codec currentStreamOutputSettings []models.StreamOutputVariant currentLatencyLevel models.LatencyLevel @@ -46,7 +48,7 @@ type HLSVariant struct { audioBitrate string // The audio bitrate isAudioPassthrough bool // Override all settings and just copy the audio stream - encoderPreset string // A collection of automatic settings for the encoder. https://trac.ffmpeg.org/wiki/Encode/H.264#crf + cpuUsageLevel int // The amount of hardware to use for encoding a stream } // VideoSize is the scaled size of the video output. @@ -81,25 +83,43 @@ func (t *Transcoder) Stop() { // Start will execute the transcoding process with the settings previously set. func (t *Transcoder) Start() { - command := t.getString() + _lastTranscoderLogMessage = "" - log.Tracef("Video transcoder started with %d stream variants.", len(t.variants)) + command := t.getString() + log.Infof("Video transcoder started using %s with %d stream variants.", t.codec.DisplayName(), len(t.variants)) if config.EnableDebugFeatures { log.Println(command) } _commandExec = exec.Command("sh", "-c", command) - err := _commandExec.Start() + stdout, err := _commandExec.StderrPipe() + if err != nil { + panic(err) + } + + err = _commandExec.Start() if err != nil { log.Errorln("Transcoder error. See transcoder.log for full output to debug.") log.Panicln(err, command) } + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + handleTranscoderMessage(line) + } + }() + err = _commandExec.Wait() if t.TranscoderCompleted != nil { t.TranscoderCompleted(err) } + + if err != nil { + log.Errorln("transcoding error. look at transcoder.log to help debug. your copy of ffmpeg may not support your selected codec of", t.codec.Name(), "https://owncast.online/docs/troubleshooting/#codecs") + } } func (t *Transcoder) getString() string { @@ -121,9 +141,12 @@ func (t *Transcoder) getString() string { hlsOptionsString = "-hls_flags " + strings.Join(hlsOptionFlags, "+") } ffmpegFlags := []string{ + `FFREPORT=file="transcoder.log":level=32`, t.ffmpegPath, "-hide_banner", "-loglevel warning", + t.codec.GlobalFlags(), + "-fflags +genpts", // Generate presentation time stamp if missing "-i ", t.input, t.getVariantsString(), @@ -133,12 +156,12 @@ func (t *Transcoder) getString() string { "-hls_time", strconv.Itoa(t.currentLatencyLevel.SecondsPerSegment), // Length of each segment "-hls_list_size", strconv.Itoa(t.currentLatencyLevel.SegmentCount), // Max # in variant playlist - "-hls_delete_threshold", "10", // Start deleting files after hls_list_size + 10 hlsOptionsString, + "-segment_format_options", "mpegts_flags=+initial_discontinuity:mpegts_copyts=1", // Video settings - "-tune", "zerolatency", // Option used for good for fast encoding and low-latency streaming (always includes iframes in each segment) - "-pix_fmt", "yuv420p", // Force yuv420p color format + t.codec.ExtraArguments(), + "-pix_fmt", t.codec.PixelFormat(), "-sc_threshold", "0", // Disable scene change detection for creating segments // Filenames @@ -149,9 +172,7 @@ func (t *Transcoder) getString() string { "-max_muxing_queue_size", "400", // Workaround for Too many packets error: https://trac.ffmpeg.org/ticket/6375?cversion=0 "-method PUT -http_persistent 0", // HLS results sent back to us will be over PUTs - "-fflags +genpts", // Generate presentation time stamp if missing localListenerAddress + "/%v/stream.m3u8", // Send HLS playlists back to us over HTTP - "2> transcoder.log", // Log to a file for debugging } return strings.Join(ffmpegFlags, " ") @@ -181,7 +202,7 @@ func getVariantFromConfigQuality(quality models.StreamOutputVariant, index int) // Set a default, reasonable preset if one is not provided. // "superfast" and "ultrafast" are generally not recommended since they look bad. // https://trac.ffmpeg.org/wiki/Encode/H.264 - variant.encoderPreset = quality.GetEncoderPreset() + variant.cpuUsageLevel = quality.CPUUsageLevel variant.SetVideoBitrate(quality.VideoBitrate) variant.SetAudioBitrate(strconv.Itoa(quality.AudioBitrate) + "k") @@ -202,6 +223,7 @@ func NewTranscoder() *Transcoder { transcoder.currentStreamOutputSettings = data.GetStreamOutputVariants() transcoder.currentLatencyLevel = data.GetStreamLatencyLevel() + transcoder.codec = getCodec(data.GetVideoCodec()) var outputPath string if data.GetS3Config().Enabled { @@ -233,12 +255,25 @@ func (v *HLSVariant) getVariantString(t *Transcoder) string { v.getAudioQualityString(), } - if v.videoSize.Width != 0 || v.videoSize.Height != 0 { - variantEncoderCommands = append(variantEncoderCommands, v.getScalingString()) + if (v.videoSize.Width != 0 || v.videoSize.Height != 0) && !v.isVideoPassthrough { + // Order here matters, you must scale before changing hardware formats + filters := []string{ + v.getScalingString(), + } + if t.codec.ExtraFilters() != "" { + filters = append(filters, t.codec.ExtraFilters()) + } + scalingAlgorithm := "bilinear" + filterString := fmt.Sprintf("-sws_flags %s -filter:v:%d \"%s\"", scalingAlgorithm, v.index, strings.Join(filters, ",")) + variantEncoderCommands = append(variantEncoderCommands, filterString) + } else if t.codec.ExtraFilters() != "" && !v.isVideoPassthrough { + filterString := fmt.Sprintf("-filter:v:%d \"%s\"", v.index, t.codec.ExtraFilters()) + variantEncoderCommands = append(variantEncoderCommands, filterString) } - if v.encoderPreset != "" { - variantEncoderCommands = append(variantEncoderCommands, fmt.Sprintf("-preset %s", v.encoderPreset)) + preset := t.codec.GetPresetForLevel(v.cpuUsageLevel) + if preset != "" { + variantEncoderCommands = append(variantEncoderCommands, fmt.Sprintf("-preset %s", preset)) } return strings.Join(variantEncoderCommands, " ") @@ -276,8 +311,7 @@ func (v *HLSVariant) SetVideoScalingHeight(height int) { } func (v *HLSVariant) getScalingString() string { - scalingAlgorithm := "bilinear" - return fmt.Sprintf("-filter:v:%d \"scale=%s\" -sws_flags %s", v.index, v.videoSize.getString(), scalingAlgorithm) + return fmt.Sprintf("scale=%s", v.videoSize.getString()) } // Video Quality @@ -292,11 +326,14 @@ func (v *HLSVariant) getVideoQualityString(t *Transcoder) string { return fmt.Sprintf("-map v:0 -c:v:%d copy", v.index) } - encoderCodec := "libx264" - - // -1 to work around segments being generated slightly larger than expected. - // https://trac.ffmpeg.org/ticket/6915?replyto=58#comment:57 - gop := (t.currentLatencyLevel.SecondsPerSegment * v.framerate) - 1 + // Determine if we should force key frames every 1, 2 or 3 frames. + isEven := t.currentLatencyLevel.SecondsPerSegment%2 == 0 + gop := v.framerate * 2 + if t.currentLatencyLevel.SecondsPerSegment == 1 { + gop = v.framerate + } else if !isEven { + gop = v.framerate * 3 + } // For limiting the output bitrate // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate @@ -304,18 +341,16 @@ func (v *HLSVariant) getVideoQualityString(t *Transcoder) string { // Adjust the max & buffer size until the output bitrate doesn't exceed the ~+10% that Apple's media validator // complains about. maxBitrate := int(float64(v.videoBitrate) * 1.06) // Max is a ~+10% over specified bitrate. - bufferSize := int(float64(v.videoBitrate) * 1.2) // How often it checks the bitrate of encoded segments to see if it's too high/low. cmd := []string{ "-map v:0", - fmt.Sprintf("-c:v:%d %s", v.index, encoderCodec), // Video codec used for this variant + fmt.Sprintf("-c:v:%d %s", v.index, t.codec.Name()), // Video codec used for this variant fmt.Sprintf("-b:v:%d %dk", v.index, v.videoBitrate), // The average bitrate for this variant fmt.Sprintf("-maxrate:v:%d %dk", v.index, maxBitrate), // The max bitrate allowed for this variant - fmt.Sprintf("-bufsize:v:%d %dk", v.index, bufferSize), // How often the encoder checks the bitrate in order to meet average/max values - fmt.Sprintf("-g:v:%d %d", v.index, gop), // How often i-frames are encoded into the segments - fmt.Sprintf("-profile:v:%d %s", v.index, "high"), // Encoding profile + fmt.Sprintf("-g:v:%d %d", v.index, gop), // Suggested interval where i-frames are encoded into the segments + fmt.Sprintf("-keyint_min:v:%d %d", v.index, gop), // minimum i-keyframe interval fmt.Sprintf("-r:v:%d %d", v.index, v.framerate), - fmt.Sprintf("-x264-params:v:%d \"scenecut=0:open_gop=0:min-keyint=%d:keyint=%d\"", v.index, gop, gop), // How often i-frames are encoded into the segments + t.codec.VariantFlags(v), } return strings.Join(cmd, " ") @@ -326,9 +361,9 @@ func (v *HLSVariant) SetVideoFramerate(framerate int) { v.framerate = framerate } -// SetEncoderPreset will set the video encoder preset of this variant. -func (v *HLSVariant) SetEncoderPreset(preset string) { - v.encoderPreset = preset +// SetCPUUsageLevel will set the hardware usage of this variant. +func (v *HLSVariant) SetCPUUsageLevel(level int) { + v.cpuUsageLevel = level } // Audio Quality @@ -377,3 +412,7 @@ func (t *Transcoder) SetIdentifier(output string) { func (t *Transcoder) SetInternalHTTPPort(port string) { t.internalListenerPort = port } + +func (t *Transcoder) SetCodec(codecName string) { + t.codec = getCodec(codecName) +} diff --git a/core/transcoder/transcoder_nvenc_test.go b/core/transcoder/transcoder_nvenc_test.go new file mode 100644 index 000000000..bcb49729f --- /dev/null +++ b/core/transcoder/transcoder_nvenc_test.go @@ -0,0 +1,48 @@ +package transcoder + +import ( + "testing" + + "github.com/owncast/owncast/models" +) + +func TestFFmpegNvencCommand(t *testing.T) { + latencyLevel := models.GetLatencyLevel(3) + codec := NvencCodec{} + + transcoder := new(Transcoder) + transcoder.ffmpegPath = "/fake/path/ffmpeg" + transcoder.SetInput("fakecontent.flv") + transcoder.SetOutputPath("fakeOutput") + transcoder.SetIdentifier("jdoieGg") + transcoder.SetInternalHTTPPort("8123") + transcoder.SetCodec(codec.Name()) + transcoder.currentLatencyLevel = latencyLevel + + variant := HLSVariant{} + variant.videoBitrate = 1200 + variant.isAudioPassthrough = true + variant.SetVideoFramerate(30) + variant.SetCPUUsageLevel(2) + transcoder.AddVariant(variant) + + variant2 := HLSVariant{} + variant2.videoBitrate = 3500 + variant2.isAudioPassthrough = true + variant2.SetVideoFramerate(24) + variant2.SetCPUUsageLevel(4) + transcoder.AddVariant(variant2) + + variant3 := HLSVariant{} + variant3.isAudioPassthrough = true + variant3.isVideoPassthrough = true + transcoder.AddVariant(variant3) + + cmd := transcoder.getString() + + expected := `FFREPORT=file="transcoder.log":level=32 /fake/path/ffmpeg -hide_banner -loglevel warning -hwaccel cuda -fflags +genpts -i fakecontent.flv -map v:0 -c:v:0 h264_nvenc -b:v:0 1200k -maxrate:v:0 1272k -g:v:0 60 -keyint_min:v:0 60 -r:v:0 30 -tune:v:0 ll -map a:0? -c:a:0 copy -preset p3 -map v:0 -c:v:1 h264_nvenc -b:v:1 3500k -maxrate:v:1 3710k -g:v:1 48 -keyint_min:v:1 48 -r:v:1 24 -tune:v:1 ll -map a:0? -c:a:1 copy -preset p5 -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset p1 -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 2 -hls_list_size 3 -segment_format_options mpegts_flags=+initial_discontinuity:mpegts_copyts=1 -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdoieGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 http://127.0.0.1:8123/%v/stream.m3u8` + + if cmd != expected { + t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) + } +} diff --git a/core/transcoder/transcoder_omx_test.go b/core/transcoder/transcoder_omx_test.go new file mode 100644 index 000000000..a734884e8 --- /dev/null +++ b/core/transcoder/transcoder_omx_test.go @@ -0,0 +1,48 @@ +package transcoder + +import ( + "testing" + + "github.com/owncast/owncast/models" +) + +func TestFFmpegOmxCommand(t *testing.T) { + latencyLevel := models.GetLatencyLevel(3) + codec := OmxCodec{} + + transcoder := new(Transcoder) + transcoder.ffmpegPath = "/fake/path/ffmpeg" + transcoder.SetInput("fakecontent.flv") + transcoder.SetOutputPath("fakeOutput") + transcoder.SetIdentifier("jdFsdfzGg") + transcoder.SetInternalHTTPPort("8123") + transcoder.SetCodec(codec.Name()) + transcoder.currentLatencyLevel = latencyLevel + + variant := HLSVariant{} + variant.videoBitrate = 1200 + variant.isAudioPassthrough = true + variant.SetVideoFramerate(30) + variant.SetCPUUsageLevel(2) + transcoder.AddVariant(variant) + + variant2 := HLSVariant{} + variant2.videoBitrate = 3500 + variant2.isAudioPassthrough = true + variant2.SetVideoFramerate(24) + variant2.SetCPUUsageLevel(4) + transcoder.AddVariant(variant2) + + variant3 := HLSVariant{} + variant3.isAudioPassthrough = true + variant3.isVideoPassthrough = true + transcoder.AddVariant(variant3) + + cmd := transcoder.getString() + + expected := `FFREPORT=file="transcoder.log":level=32 /fake/path/ffmpeg -hide_banner -loglevel warning -fflags +genpts -i fakecontent.flv -map v:0 -c:v:0 h264_omx -b:v:0 1200k -maxrate:v:0 1272k -g:v:0 60 -keyint_min:v:0 60 -r:v:0 30 -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_omx -b:v:1 3500k -maxrate:v:1 3710k -g:v:1 48 -keyint_min:v:1 48 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 2 -hls_list_size 3 -segment_format_options mpegts_flags=+initial_discontinuity:mpegts_copyts=1 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdFsdfzGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 http://127.0.0.1:8123/%v/stream.m3u8` + + if cmd != expected { + t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) + } +} diff --git a/core/transcoder/transcoder_test.go b/core/transcoder/transcoder_test.go deleted file mode 100644 index eec72ce13..000000000 --- a/core/transcoder/transcoder_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package transcoder - -import ( - "testing" - - "github.com/owncast/owncast/models" -) - -func TestFFmpegCommand(t *testing.T) { - latencyLevel := models.GetLatencyLevel(3) - - transcoder := new(Transcoder) - transcoder.ffmpegPath = "/fake/path/ffmpeg" - transcoder.SetInput("fakecontent.flv") - transcoder.SetOutputPath("fakeOutput") - transcoder.SetIdentifier("jdofFGg") - transcoder.SetInternalHTTPPort("8123") - transcoder.currentLatencyLevel = latencyLevel - - variant := HLSVariant{} - variant.videoBitrate = 1200 - variant.isAudioPassthrough = true - variant.encoderPreset = "veryfast" - variant.SetVideoFramerate(30) - transcoder.AddVariant(variant) - - variant2 := HLSVariant{} - variant2.videoBitrate = 3500 - variant2.isAudioPassthrough = true - variant2.encoderPreset = "faster" - variant2.SetVideoFramerate(24) - transcoder.AddVariant(variant2) - - variant3 := HLSVariant{} - variant3.isAudioPassthrough = true - variant3.isVideoPassthrough = true - transcoder.AddVariant(variant3) - - cmd := transcoder.getString() - - expected := `/fake/path/ffmpeg -hide_banner -loglevel warning -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1200k -maxrate:v:0 1272k -bufsize:v:0 1440k -g:v:0 89 -profile:v:0 high -r:v:0 30 -x264-params:v:0 "scenecut=0:open_gop=0:min-keyint=89:keyint=89" -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3500k -maxrate:v:1 3710k -bufsize:v:1 4200k -g:v:1 71 -profile:v:1 high -r:v:1 24 -x264-params:v:1 "scenecut=0:open_gop=0:min-keyint=71:keyint=71" -map a:0? -c:a:1 copy -preset faster -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 3 -hls_list_size 3 -hls_delete_threshold 10 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 -fflags +genpts http://127.0.0.1:8123/%v/stream.m3u8 2> transcoder.log` - - if cmd != expected { - t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) - } -} diff --git a/core/transcoder/transcoder_vaapi_test.go b/core/transcoder/transcoder_vaapi_test.go new file mode 100644 index 000000000..24db257e9 --- /dev/null +++ b/core/transcoder/transcoder_vaapi_test.go @@ -0,0 +1,48 @@ +package transcoder + +import ( + "testing" + + "github.com/owncast/owncast/models" +) + +func TestFFmpegVaapiCommand(t *testing.T) { + latencyLevel := models.GetLatencyLevel(3) + codec := VaapiCodec{} + + transcoder := new(Transcoder) + transcoder.ffmpegPath = "/fake/path/ffmpeg" + transcoder.SetInput("fakecontent.flv") + transcoder.SetOutputPath("fakeOutput") + transcoder.SetIdentifier("jdofFGg") + transcoder.SetInternalHTTPPort("8123") + transcoder.SetCodec(codec.Name()) + transcoder.currentLatencyLevel = latencyLevel + + variant := HLSVariant{} + variant.videoBitrate = 1200 + variant.isAudioPassthrough = true + variant.SetVideoFramerate(30) + variant.SetCPUUsageLevel(2) + transcoder.AddVariant(variant) + + variant2 := HLSVariant{} + variant2.videoBitrate = 3500 + variant2.isAudioPassthrough = true + variant2.SetVideoFramerate(24) + variant2.SetCPUUsageLevel(4) + transcoder.AddVariant(variant2) + + variant3 := HLSVariant{} + variant3.isAudioPassthrough = true + variant3.isVideoPassthrough = true + transcoder.AddVariant(variant3) + + cmd := transcoder.getString() + + expected := `FFREPORT=file="transcoder.log":level=32 /fake/path/ffmpeg -hide_banner -loglevel warning -vaapi_device /dev/dri/renderD128 -fflags +genpts -i fakecontent.flv -map v:0 -c:v:0 h264_vaapi -b:v:0 1200k -maxrate:v:0 1272k -g:v:0 60 -keyint_min:v:0 60 -r:v:0 30 -map a:0? -c:a:0 copy -filter:v:0 "format=nv12,hwupload" -preset veryfast -map v:0 -c:v:1 h264_vaapi -b:v:1 3500k -maxrate:v:1 3710k -g:v:1 48 -keyint_min:v:1 48 -r:v:1 24 -map a:0? -c:a:1 copy -filter:v:1 "format=nv12,hwupload" -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 2 -hls_list_size 3 -segment_format_options mpegts_flags=+initial_discontinuity:mpegts_copyts=1 -pix_fmt vaapi_vld -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 http://127.0.0.1:8123/%v/stream.m3u8` + + if cmd != expected { + t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) + } +} diff --git a/core/transcoder/transcoder_x264_test.go b/core/transcoder/transcoder_x264_test.go new file mode 100644 index 000000000..d5c42fea7 --- /dev/null +++ b/core/transcoder/transcoder_x264_test.go @@ -0,0 +1,48 @@ +package transcoder + +import ( + "testing" + + "github.com/owncast/owncast/models" +) + +func TestFFmpegx264Command(t *testing.T) { + latencyLevel := models.GetLatencyLevel(3) + codec := Libx264Codec{} + + transcoder := new(Transcoder) + transcoder.ffmpegPath = "/fake/path/ffmpeg" + transcoder.SetInput("fakecontent.flv") + transcoder.SetOutputPath("fakeOutput") + transcoder.SetIdentifier("jdofFGg") + transcoder.SetInternalHTTPPort("8123") + transcoder.SetCodec(codec.Name()) + transcoder.currentLatencyLevel = latencyLevel + + variant := HLSVariant{} + variant.videoBitrate = 1200 + variant.isAudioPassthrough = true + variant.SetVideoFramerate(30) + variant.SetCPUUsageLevel(2) + transcoder.AddVariant(variant) + + variant2 := HLSVariant{} + variant2.videoBitrate = 3500 + variant2.isAudioPassthrough = true + variant2.SetVideoFramerate(24) + variant2.SetCPUUsageLevel(4) + transcoder.AddVariant(variant2) + + variant3 := HLSVariant{} + variant3.isAudioPassthrough = true + variant3.isVideoPassthrough = true + transcoder.AddVariant(variant3) + + cmd := transcoder.getString() + + expected := `FFREPORT=file="transcoder.log":level=32 /fake/path/ffmpeg -hide_banner -loglevel warning -fflags +genpts -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1200k -maxrate:v:0 1272k -g:v:0 60 -keyint_min:v:0 60 -r:v:0 30 -x264-params:v:0 "scenecut=0:open_gop=0" -bufsize:v:0 1440k -profile:v:0 high -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3500k -maxrate:v:1 3710k -g:v:1 48 -keyint_min:v:1 48 -r:v:1 24 -x264-params:v:1 "scenecut=0:open_gop=0" -bufsize:v:1 4200k -profile:v:1 high -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 " -f hls -hls_time 2 -hls_list_size 3 -segment_format_options mpegts_flags=+initial_discontinuity:mpegts_copyts=1 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -strftime 1 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg%s.ts -max_muxing_queue_size 400 -method PUT -http_persistent 0 http://127.0.0.1:8123/%v/stream.m3u8` + + if cmd != expected { + t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected) + } +} diff --git a/core/transcoder/utils.go b/core/transcoder/utils.go new file mode 100644 index 000000000..aba015468 --- /dev/null +++ b/core/transcoder/utils.go @@ -0,0 +1,81 @@ +package transcoder + +import ( + "strings" + "sync" + + log "github.com/sirupsen/logrus" +) + +var _lastTranscoderLogMessage = "" +var l = &sync.RWMutex{} + +var errorMap = map[string]string{ + "Unrecognized option 'vaapi_device'": "you are likely trying to utilize a vaapi codec, but your version of ffmpeg or your hardware doesn't support it. change your codec to libx264 and restart your stream", + "unable to open display": "your copy of ffmpeg is likely installed via snap packages. please uninstall and re-install via a non-snap method. https://owncast.online/docs/troubleshooting/#misc-video-issues", + "Failed to open file 'http://127.0.0.1": "error transcoding. make sure your version of ffmpeg is compatible with your selected codec or is recent enough https://owncast.online/docs/troubleshooting/#codecs", + "can't configure encoder": "error with codec. if your copy of ffmpeg or your hardware does not support your selected codec you may need to select another", + "Unable to parse option value": "you are likely trying to utilize a specific codec, but your version of ffmpeg or your hardware doesn't support it. either fix your ffmpeg install or try changing your codec to libx264 and restart your stream", + "OpenEncodeSessionEx failed: out of memory": "your NVIDIA gpu is limiting the number of concurrent stream qualities you can support. remove a stream output variant and try again.", + "Cannot use rename on non file protocol, this may lead to races and temporary partial files": "", + "No VA display found for device": "vaapi not enabled. either your copy of ffmpeg does not support it, your hardware does not support it, or you need to install additional drivers for your hardware.", + "Could not find a valid device": "your codec is either not supported or not configured properly", + "H.264 bitstream error": "transcoding content error playback issues may arise. you may want to use the default codec if you are not already.", + + `Unknown encoder 'h264_qsv'`: "your copy of ffmpeg does not have support for Intel QuickSync encoding (h264_qsv). change the selected codec in your video settings", + `Unknown encoder 'h264_vaapi'`: "your copy of ffmpeg does not have support for VA-API encoding (h264_vaapi). change the selected codec in your video settings", + `Unknown encoder 'h264_nvenc'`: "your copy of ffmpeg does not have support for NVIDIA hardware encoding (h264_nvenc). change the selected codec in your video settings", + `Unknown encoder 'h264_x264'`: "your copy of ffmpeg does not have support for the default x264 codec (h264_x264). download a version of ffmpeg that supports this.", + `Unrecognized option 'x264-params`: "your copy of ffmpeg does not have support for the default libx264 codec (h264_x264). download a version of ffmpeg that supports this.", + + // Generic error for a codec + "Unrecognized option": "error with codec. if your copy of ffmpeg or your hardware does not support your selected codec you may need to select another", +} + +var ignoredErrors = []string{ + "Duplicated segment filename detected", + "Error while opening encoder for output stream", + "Unable to parse option value", + "Last message repeated", + "Option not found", + "use of closed network connection", + "URL read error: End of file", + "upload playlist failed, will retry with a new http session", + "VBV underflow", + "Cannot use rename on non file protocol", +} + +func handleTranscoderMessage(message string) { + log.Debugln(message) + + l.Lock() + defer l.Unlock() + + // Ignore certain messages that we don't care about. + for _, error := range ignoredErrors { + if strings.Contains(message, error) { + return + } + } + + // Convert specific transcoding messages to human-readable messages. + for error, displayMessage := range errorMap { + if strings.Contains(message, error) { + message = displayMessage + break + } + } + + if message == "" { + return + } + + // No good comes from a flood of repeated messages. + if message == _lastTranscoderLogMessage { + return + } + + log.Error(message) + + _lastTranscoderLogMessage = message +} diff --git a/doc/api/index.html b/doc/api/index.html index 08625e2d9..3b73c7ce2 100644 --- a/doc/api/index.html +++ b/doc/api/index.html @@ -15,10 +15,10 @@