From 0c1b02380e78a7c098989e2fb473b28972477774 Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Fri, 10 Mar 2023 01:25:55 +0100 Subject: [PATCH] Simple hardware encoding (#3419) * HW Accel * CUDA Docker build and adjust the NVENC encoder * Removed NVENC preset Using legacy presets is removed in SDK 12 and deprecated since SDK 10. This commit removed the preset to allow ffmpeg to select the default one. --------- Co-authored-by: Nodude <> Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- Makefile | 5 + docker/build/x86_64/Dockerfile-CUDA | 53 +++++ graphql/documents/data/config.graphql | 1 + graphql/schema/types/config.graphql | 4 + internal/api/resolver_mutation_configure.go | 3 + internal/api/resolver_query_configuration.go | 93 ++++---- internal/manager/config/config.go | 9 +- internal/manager/manager.go | 8 +- pkg/ffmpeg/codec.go | 1 + pkg/ffmpeg/codec_hardware.go | 207 ++++++++++++++++++ pkg/ffmpeg/ffmpeg.go | 16 +- pkg/ffmpeg/filter.go | 33 ++- pkg/ffmpeg/frame_rate.go | 2 +- pkg/ffmpeg/generate.go | 4 +- pkg/ffmpeg/stream.go | 5 +- pkg/ffmpeg/stream_segmented.go | 57 +++-- pkg/ffmpeg/stream_transcode.go | 119 ++++++++-- pkg/hash/videophash/phash.go | 6 +- pkg/image/thumbnail.go | 4 +- pkg/scene/generate/generator.go | 2 +- .../Settings/SettingsSystemPanel.tsx | 8 + ui/v2.5/src/docs/en/Changelog/v0200.md | 1 + ui/v2.5/src/docs/en/Manual/Configuration.md | 4 + ui/v2.5/src/locales/en-GB.json | 4 + 24 files changed, 537 insertions(+), 112 deletions(-) create mode 100644 docker/build/x86_64/Dockerfile-CUDA create mode 100644 pkg/ffmpeg/codec_hardware.go diff --git a/Makefile b/Makefile index 85167f990..b2630a4f0 100644 --- a/Makefile +++ b/Makefile @@ -278,3 +278,8 @@ validate-backend: lint it .PHONY: docker-build docker-build: pre-build docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/build -f docker/build/x86_64/Dockerfile . + +# locally builds and tags a 'stash/cuda-build' docker image +.PHONY: docker-cuda-build +docker-cuda-build: pre-build + docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/cuda-build -f docker/build/x86_64/Dockerfile-CUDA . diff --git a/docker/build/x86_64/Dockerfile-CUDA b/docker/build/x86_64/Dockerfile-CUDA new file mode 100644 index 000000000..74970cfd5 --- /dev/null +++ b/docker/build/x86_64/Dockerfile-CUDA @@ -0,0 +1,53 @@ +# This dockerfile should be built with `make docker-cuda-build` from the stash root. + +# Build Frontend +FROM node:alpine as frontend +RUN apk add --no-cache make +## cache node_modules separately +COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/ +WORKDIR /stash +RUN yarn --cwd ui/v2.5 install --frozen-lockfile. +COPY Makefile /stash/ +COPY ./graphql /stash/graphql/ +COPY ./ui /stash/ui/ +RUN make generate-frontend +ARG GITHASH +ARG STASH_VERSION +RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui + +# Build Backend +FROM golang:1.19-bullseye as backend +RUN apt update && apt install -y build-essential golang +WORKDIR /stash +COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ +COPY ./scripts /stash/scripts/ +COPY ./vendor /stash/vendor/ +COPY ./pkg /stash/pkg/ +COPY ./cmd /stash/cmd +COPY ./internal /stash/internal +COPY --from=frontend /stash /stash/ +RUN make generate-backend +ARG GITHASH +ARG STASH_VERSION +RUN make build + +# Final Runnable Image +FROM nvidia/cuda:12.0.1-runtime-ubuntu22.04 +RUN apt update +RUN apt upgrade -y +RUN apt install -y ca-certificates libvips-tools ffmpeg wget +RUN rm -rf /var/lib/apt/lists/* +COPY --from=backend /stash/stash /usr/bin/ + +# NVENC Patch +RUN mkdir -p /usr/local/bin /patched-lib +RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh -O /usr/local/bin/patch.sh +RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh -O /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/patch.sh /usr/local/bin/docker-entrypoint.sh /usr/bin/stash + +ENV LANG C.UTF-8 +ENV NVIDIA_VISIBLE_DEVICES all +ENV NVIDIA_DRIVER_CAPABILITIES=video,utility +ENV STASH_CONFIG_FILE=/root/.stash/config.yml +EXPOSE 9999 +ENTRYPOINT ["docker-entrypoint.sh", "stash"] diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index f102e2171..40c8e9228 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -19,6 +19,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { previewExcludeStart previewExcludeEnd previewPreset + transcodeHardwareAcceleration maxTranscodeSize maxStreamingTranscodeSize writeImageThumbnails diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 88ffb36dd..9e7ee529e 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -67,6 +67,8 @@ input ConfigGeneralInput { previewExcludeEnd: String """Preset when generating preview""" previewPreset: PreviewPreset + """Transcode Hardware Acceleration""" + transcodeHardwareAcceleration: Boolean """Max generated transcode size""" maxTranscodeSize: StreamingResolutionEnum """Max streaming transcode size""" @@ -170,6 +172,8 @@ type ConfigGeneralResult { previewExcludeEnd: String! """Preset when generating preview""" previewPreset: PreviewPreset! + """Transcode Hardware Acceleration""" + transcodeHardwareAcceleration: Boolean! """Max generated transcode size""" maxTranscodeSize: StreamingResolutionEnum """Max streaming transcode size""" diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 56c14867b..81c3138f5 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -181,6 +181,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen c.Set(config.PreviewPreset, input.PreviewPreset.String()) } + if input.TranscodeHardwareAcceleration != nil { + c.Set(config.TranscodeHardwareAcceleration, *input.TranscodeHardwareAcceleration) + } if input.MaxTranscodeSize != nil { c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index b9aae65de..65b922d3e 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -83,52 +83,53 @@ func makeConfigGeneralResult() *ConfigGeneralResult { scraperCDPPath := config.GetScraperCDPPath() return &ConfigGeneralResult{ - Stashes: config.GetStashPaths(), - DatabasePath: config.GetDatabasePath(), - BackupDirectoryPath: config.GetBackupDirectoryPath(), - GeneratedPath: config.GetGeneratedPath(), - MetadataPath: config.GetMetadataPath(), - ConfigFilePath: config.GetConfigFile(), - ScrapersPath: config.GetScrapersPath(), - CachePath: config.GetCachePath(), - CalculateMd5: config.IsCalculateMD5(), - VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), - ParallelTasks: config.GetParallelTasks(), - PreviewAudio: config.GetPreviewAudio(), - PreviewSegments: config.GetPreviewSegments(), - PreviewSegmentDuration: config.GetPreviewSegmentDuration(), - PreviewExcludeStart: config.GetPreviewExcludeStart(), - PreviewExcludeEnd: config.GetPreviewExcludeEnd(), - PreviewPreset: config.GetPreviewPreset(), - MaxTranscodeSize: &maxTranscodeSize, - MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, - WriteImageThumbnails: config.IsWriteImageThumbnails(), - GalleryCoverRegex: config.GetGalleryCoverRegex(), - APIKey: config.GetAPIKey(), - Username: config.GetUsername(), - Password: config.GetPasswordHash(), - MaxSessionAge: config.GetMaxSessionAge(), - LogFile: &logFile, - LogOut: config.GetLogOut(), - LogLevel: config.GetLogLevel(), - LogAccess: config.GetLogAccess(), - VideoExtensions: config.GetVideoExtensions(), - ImageExtensions: config.GetImageExtensions(), - GalleryExtensions: config.GetGalleryExtensions(), - CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(), - Excludes: config.GetExcludes(), - ImageExcludes: config.GetImageExcludes(), - CustomPerformerImageLocation: &customPerformerImageLocation, - ScraperUserAgent: &scraperUserAgent, - ScraperCertCheck: config.GetScraperCertCheck(), - ScraperCDPPath: &scraperCDPPath, - StashBoxes: config.GetStashBoxes(), - PythonPath: config.GetPythonPath(), - TranscodeInputArgs: config.GetTranscodeInputArgs(), - TranscodeOutputArgs: config.GetTranscodeOutputArgs(), - LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(), - LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(), - DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(), + Stashes: config.GetStashPaths(), + DatabasePath: config.GetDatabasePath(), + BackupDirectoryPath: config.GetBackupDirectoryPath(), + GeneratedPath: config.GetGeneratedPath(), + MetadataPath: config.GetMetadataPath(), + ConfigFilePath: config.GetConfigFile(), + ScrapersPath: config.GetScrapersPath(), + CachePath: config.GetCachePath(), + CalculateMd5: config.IsCalculateMD5(), + VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), + ParallelTasks: config.GetParallelTasks(), + PreviewAudio: config.GetPreviewAudio(), + PreviewSegments: config.GetPreviewSegments(), + PreviewSegmentDuration: config.GetPreviewSegmentDuration(), + PreviewExcludeStart: config.GetPreviewExcludeStart(), + PreviewExcludeEnd: config.GetPreviewExcludeEnd(), + PreviewPreset: config.GetPreviewPreset(), + TranscodeHardwareAcceleration: config.GetTranscodeHardwareAcceleration(), + MaxTranscodeSize: &maxTranscodeSize, + MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, + WriteImageThumbnails: config.IsWriteImageThumbnails(), + GalleryCoverRegex: config.GetGalleryCoverRegex(), + APIKey: config.GetAPIKey(), + Username: config.GetUsername(), + Password: config.GetPasswordHash(), + MaxSessionAge: config.GetMaxSessionAge(), + LogFile: &logFile, + LogOut: config.GetLogOut(), + LogLevel: config.GetLogLevel(), + LogAccess: config.GetLogAccess(), + VideoExtensions: config.GetVideoExtensions(), + ImageExtensions: config.GetImageExtensions(), + GalleryExtensions: config.GetGalleryExtensions(), + CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(), + Excludes: config.GetExcludes(), + ImageExcludes: config.GetImageExcludes(), + CustomPerformerImageLocation: &customPerformerImageLocation, + ScraperUserAgent: &scraperUserAgent, + ScraperCertCheck: config.GetScraperCertCheck(), + ScraperCDPPath: &scraperCDPPath, + StashBoxes: config.GetStashBoxes(), + PythonPath: config.GetPythonPath(), + TranscodeInputArgs: config.GetTranscodeInputArgs(), + TranscodeOutputArgs: config.GetTranscodeOutputArgs(), + LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(), + LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(), + DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(), } } diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index bea1381e4..1ba0ce5b3 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -69,11 +69,12 @@ const ( ParallelTasks = "parallel_tasks" parallelTasksDefault = 1 + PreviewPreset = "preview_preset" + TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration" + SequentialScanning = "sequential_scanning" SequentialScanningDefault = false - PreviewPreset = "preview_preset" - PreviewAudio = "preview_audio" previewAudioDefault = true @@ -803,6 +804,10 @@ func (i *Instance) GetPreviewPreset() models.PreviewPreset { return models.PreviewPreset(ret) } +func (i *Instance) GetTranscodeHardwareAcceleration() bool { + return i.getBool(TranscodeHardwareAcceleration) +} + func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum { ret := i.getString(MaxTranscodeSize) diff --git a/internal/manager/manager.go b/internal/manager/manager.go index d12930c2f..1ed4d163a 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -110,7 +110,7 @@ type Manager struct { Paths *paths.Paths - FFMPEG ffmpeg.FFMpeg + FFMPEG *ffmpeg.FFMpeg FFProbe ffmpeg.FFProbe StreamManager *ffmpeg.StreamManager @@ -431,8 +431,10 @@ func initFFMPEG(ctx context.Context) error { } } - instance.FFMPEG = ffmpeg.FFMpeg(ffmpegPath) + instance.FFMPEG = ffmpeg.NewEncoder(ffmpegPath) instance.FFProbe = ffmpeg.FFProbe(ffprobePath) + + instance.FFMPEG.InitHWSupport(ctx) instance.RefreshStreamManager() } @@ -681,7 +683,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { } func (s *Manager) validateFFMPEG() error { - if s.FFMPEG == "" || s.FFProbe == "" { + if s.FFMPEG == nil || s.FFProbe == "" { return errors.New("missing ffmpeg and/or ffprobe") } diff --git a/pkg/ffmpeg/codec.go b/pkg/ffmpeg/codec.go index bb7734030..1195fdc3d 100644 --- a/pkg/ffmpeg/codec.go +++ b/pkg/ffmpeg/codec.go @@ -11,6 +11,7 @@ func (c VideoCodec) Args() []string { } var ( + // Software codec's VideoCodecLibX264 VideoCodec = "libx264" VideoCodecLibWebP VideoCodec = "libwebp" VideoCodecBMP VideoCodec = "bmp" diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go new file mode 100644 index 000000000..6ab2e7870 --- /dev/null +++ b/pkg/ffmpeg/codec_hardware.go @@ -0,0 +1,207 @@ +package ffmpeg + +import ( + "bytes" + "context" + "fmt" + "regexp" + "strings" + + "github.com/stashapp/stash/pkg/logger" +) + +var ( + // Hardware codec's + VideoCodecN264 VideoCodec = "h264_nvenc" + VideoCodecI264 VideoCodec = "h264_qsv" + VideoCodecA264 VideoCodec = "h264_amf" + VideoCodecM264 VideoCodec = "h264_videotoolbox" + VideoCodecV264 VideoCodec = "h264_vaapi" + VideoCodecR264 VideoCodec = "h264_v4l2m2m" + VideoCodecO264 VideoCodec = "h264_omx" + VideoCodecIVP9 VideoCodec = "vp9_qsv" + VideoCodecVVP9 VideoCodec = "vp9_vaapi" + VideoCodecVVPX VideoCodec = "vp8_vaapi" +) + +// Tests all (given) hardware codec's +func (f *FFMpeg) InitHWSupport(ctx context.Context) { + var hwCodecSupport []VideoCodec + + for _, codec := range []VideoCodec{ + VideoCodecN264, + VideoCodecI264, + VideoCodecV264, + VideoCodecR264, + VideoCodecIVP9, + VideoCodecVVP9, + } { + var args Args + args = append(args, "-hide_banner") + args = args.LogLevel(LogLevelWarning) + args = f.hwDeviceInit(args, codec) + args = args.Format("lavfi") + args = args.Input("color=c=red") + args = args.Duration(0.1) + + videoFilter := f.hwFilterInit(codec) + // Test scaling + videoFilter = videoFilter.ScaleDimensions(-2, 160) + videoFilter = f.hwCodecFilter(videoFilter, codec) + args = append(args, CodecInit(codec)...) + args = args.VideoFilter(videoFilter) + + args = args.Format("null") + args = args.Output("-") + + cmd := f.Command(ctx, args) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + logger.Debugf("[InitHWSupport] error starting command: %w", err) + continue + } + + if err := cmd.Wait(); err != nil { + errOutput := stderr.String() + + if len(errOutput) == 0 { + errOutput = err.Error() + } + + logger.Debugf("[InitHWSupport] Codec %s not supported. Error output:\n%s", codec, errOutput) + } else { + hwCodecSupport = append(hwCodecSupport, codec) + } + } + + outstr := "[InitHWSupport] Supported HW codecs:\n" + for _, codec := range hwCodecSupport { + outstr += fmt.Sprintf("\t%s\n", codec) + } + logger.Info(outstr) + + f.hwCodecSupport = hwCodecSupport +} + +// Prepend input for hardware encoding only +func (f *FFMpeg) hwDeviceInit(args Args, codec VideoCodec) Args { + switch codec { + case VideoCodecN264: + args = append(args, "-hwaccel_device") + args = append(args, "0") + case VideoCodecV264, + VideoCodecVVP9: + args = append(args, "-vaapi_device") + args = append(args, "/dev/dri/renderD128") + case VideoCodecI264, + VideoCodecIVP9: + args = append(args, "-init_hw_device") + args = append(args, "qsv=hw") + args = append(args, "-filter_hw_device") + args = append(args, "hw") + } + + return args +} + +// Initialise a video filter for HW encoding +func (f *FFMpeg) hwFilterInit(codec VideoCodec) VideoFilter { + var videoFilter VideoFilter + switch codec { + case VideoCodecV264, + VideoCodecVVP9: + videoFilter = videoFilter.Append("format=nv12") + videoFilter = videoFilter.Append("hwupload") + case VideoCodecN264: + videoFilter = videoFilter.Append("format=nv12") + videoFilter = videoFilter.Append("hwupload_cuda") + case VideoCodecI264, + VideoCodecIVP9: + videoFilter = videoFilter.Append("hwupload=extra_hw_frames=64") + videoFilter = videoFilter.Append("format=qsv") + } + + return videoFilter +} + +// Replace video filter scaling with hardware scaling for full hardware transcoding +func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec) VideoFilter { + sargs := string(args) + + if strings.Contains(sargs, "scale=") { + switch codec { + case VideoCodecN264: + args = VideoFilter(strings.Replace(sargs, "scale=", "scale_cuda=", 1)) + case VideoCodecV264, + VideoCodecVVP9: + args = VideoFilter(strings.Replace(sargs, "scale=", "scale_vaapi=", 1)) + case VideoCodecI264, + VideoCodecIVP9: + // BUG: [scale_qsv]: Size values less than -1 are not acceptable. + // Fix: Replace all instances of -2 with -1 in a scale operation + re := regexp.MustCompile(`(scale=)([\d:]*)(-2)(.*)`) + sargs = re.ReplaceAllString(sargs, "scale=$2-1$4") + args = VideoFilter(strings.Replace(sargs, "scale=", "scale_qsv=", 1)) + } + } + + return args +} + +// Returns the max resolution for a given codec, or a default +func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec, dW int, dH int) (int, int) { + if codec == VideoCodecN264 { + return 4096, 4096 + } + + return dW, dH +} + +// Return a maxres filter +func (f *FFMpeg) hwMaxResFilter(codec VideoCodec, width int, height int, max int) VideoFilter { + videoFilter := f.hwFilterInit(codec) + maxWidth, maxHeight := f.hwCodecMaxRes(codec, width, height) + videoFilter = videoFilter.ScaleMaxLM(width, height, max, maxWidth, maxHeight) + return f.hwCodecFilter(videoFilter, codec) +} + +// Return if a hardware accelerated for HLS is available +func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec { + for _, element := range f.hwCodecSupport { + switch element { + case VideoCodecN264, + VideoCodecI264, + VideoCodecV264, + VideoCodecR264: + return &element + } + } + return nil +} + +// Return if a hardware accelerated codec for MP4 is available +func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec { + for _, element := range f.hwCodecSupport { + switch element { + case VideoCodecN264, + VideoCodecI264: + return &element + } + } + return nil +} + +// Return if a hardware accelerated codec for WebM is available +func (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec { + for _, element := range f.hwCodecSupport { + switch element { + case VideoCodecIVP9, + VideoCodecVVP9: + return &element + } + } + return nil +} diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go index 3a961bbed..58621bc70 100644 --- a/pkg/ffmpeg/ffmpeg.go +++ b/pkg/ffmpeg/ffmpeg.go @@ -9,9 +9,21 @@ import ( ) // FFMpeg provides an interface to ffmpeg. -type FFMpeg string +type FFMpeg struct { + ffmpeg string + hwCodecSupport []VideoCodec +} + +// Creates a new FFMpeg encoder +func NewEncoder(ffmpegPath string) *FFMpeg { + ret := &FFMpeg{ + ffmpeg: ffmpegPath, + } + + return ret +} // Returns an exec.Cmd that can be used to run ffmpeg using args. func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd { - return stashExec.CommandContext(ctx, string(*f), args...) + return stashExec.CommandContext(ctx, string(f.ffmpeg), args...) } diff --git a/pkg/ffmpeg/filter.go b/pkg/ffmpeg/filter.go index 8b9c94122..52be57c9c 100644 --- a/pkg/ffmpeg/filter.go +++ b/pkg/ffmpeg/filter.go @@ -1,6 +1,8 @@ package ffmpeg -import "fmt" +import ( + "fmt" +) // VideoFilter represents video filter parameters to be passed to ffmpeg. type VideoFilter string @@ -57,6 +59,35 @@ func (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter return f.ScaleDimensions(maxSize, -2) } +// ScaleMaxLM returns a VideoFilter scaling to maxSize with respect to a max size. +func (f VideoFilter) ScaleMaxLM(width int, height int, reqHeight int, maxWidth int, maxHeight int) VideoFilter { + // calculate the aspect ratio of the current resolution + aspectRatio := width / height + + // find the max height + desiredHeight := reqHeight + if desiredHeight == 0 { + desiredHeight = height + } + + // calculate the desired width based on the desired height and the aspect ratio + desiredWidth := int(desiredHeight * aspectRatio) + + // check which dimension to scale based on the maximum resolution + if desiredHeight > maxHeight || desiredWidth > maxWidth { + if desiredHeight-maxHeight > desiredWidth-maxWidth { + // scale the height down to the maximum height + return f.ScaleDimensions(-2, maxHeight) + } else { + // scale the width down to the maximum width + return f.ScaleDimensions(maxWidth, -2) + } + } + + // the current resolution can be scaled to the desired height without exceeding the maximum resolution + return f.ScaleMax(width, height, reqHeight) +} + // Fps returns a VideoFilter setting the frames per second. func (f VideoFilter) Fps(fps int) VideoFilter { return f.Append(fmt.Sprintf("fps=%v", fps)) diff --git a/pkg/ffmpeg/frame_rate.go b/pkg/ffmpeg/frame_rate.go index 07585b67e..271f6c4cb 100644 --- a/pkg/ffmpeg/frame_rate.go +++ b/pkg/ffmpeg/frame_rate.go @@ -16,7 +16,7 @@ type FrameInfo struct { // CalculateFrameRate calculates the frame rate and number of frames of the video file. // Used where the frame rate or NbFrames is missing or invalid in the ffprobe output. -func (f FFMpeg) CalculateFrameRate(ctx context.Context, v *VideoFile) (*FrameInfo, error) { +func (f *FFMpeg) CalculateFrameRate(ctx context.Context, v *VideoFile) (*FrameInfo, error) { var args Args args = append(args, "-nostats") args = args.Input(v.Path). diff --git a/pkg/ffmpeg/generate.go b/pkg/ffmpeg/generate.go index 934bd1500..5b3a888d3 100644 --- a/pkg/ffmpeg/generate.go +++ b/pkg/ffmpeg/generate.go @@ -13,7 +13,7 @@ import ( // Generate runs ffmpeg with the given args and waits for it to finish. // Returns an error if the command fails. If the command fails, the return // value will be of type *exec.ExitError. -func (f FFMpeg) Generate(ctx context.Context, args Args) error { +func (f *FFMpeg) Generate(ctx context.Context, args Args) error { cmd := f.Command(ctx, args) var stderr bytes.Buffer @@ -36,7 +36,7 @@ func (f FFMpeg) Generate(ctx context.Context, args Args) error { } // GenerateOutput runs ffmpeg with the given args and returns it standard output. -func (f FFMpeg) GenerateOutput(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) { +func (f *FFMpeg) GenerateOutput(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) { cmd := f.Command(ctx, args) cmd.Stdin = stdin diff --git a/pkg/ffmpeg/stream.go b/pkg/ffmpeg/stream.go index e71bf05b1..80c1d14a7 100644 --- a/pkg/ffmpeg/stream.go +++ b/pkg/ffmpeg/stream.go @@ -22,7 +22,7 @@ const ( type StreamManager struct { cacheDir string - encoder FFMpeg + encoder *FFMpeg ffprobe FFProbe config StreamManagerConfig @@ -39,9 +39,10 @@ type StreamManagerConfig interface { GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum GetLiveTranscodeInputArgs() []string GetLiveTranscodeOutputArgs() []string + GetTranscodeHardwareAcceleration() bool } -func NewStreamManager(cacheDir string, encoder FFMpeg, ffprobe FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager { +func NewStreamManager(cacheDir string, encoder *FFMpeg, ffprobe FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager { if cacheDir == "" { logger.Warn("cache directory is not set. Live HLS transcoding will be disabled") } diff --git a/pkg/ffmpeg/stream_segmented.go b/pkg/ffmpeg/stream_segmented.go index 3632bf779..b12283fc9 100644 --- a/pkg/ffmpeg/stream_segmented.go +++ b/pkg/ffmpeg/stream_segmented.go @@ -51,7 +51,7 @@ type StreamType struct { Name string SegmentType *SegmentType ServeManifest func(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *file.VideoFile, resolution string) - Args func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) Args + Args func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) Args } var ( @@ -59,15 +59,11 @@ var ( Name: "hls", SegmentType: SegmentTypeTS, ServeManifest: serveHLSManifest, - Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { + Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { + args = CodecInit(codec) args = append(args, - "-c:v", "libx264", - "-pix_fmt", "yuv420p", - "-preset", "veryfast", - "-crf", "25", "-flags", "+cgop", "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength), - "-sc_threshold", "0", ) args = args.VideoFilter(videoFilter) if videoOnly { @@ -97,10 +93,8 @@ var ( Name: "hls-copy", SegmentType: SegmentTypeTS, ServeManifest: serveHLSManifest, - Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { - args = append(args, - "-c:v", "copy", - ) + Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { + args = CodecInit(codec) if videoOnly { args = append(args, "-an") } else { @@ -128,23 +122,19 @@ var ( Name: "dash-v", SegmentType: SegmentTypeWEBMVideo, ServeManifest: serveDASHManifest, - Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { + Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { // only generate the actual init segment (init_v.webm) // when generating the first segment init := ".init" if segment == 0 { init = "init" } + + args = CodecInit(codec) args = append(args, - "-c:v", "libvpx-vp9", - "-pix_fmt", "yuv420p", - "-deadline", "realtime", - "-cpu-used", "5", - "-row-mt", "1", - "-crf", "30", - "-b:v", "0", "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength), ) + args = args.VideoFilter(videoFilter) args = append(args, "-copyts", @@ -162,7 +152,7 @@ var ( Name: "dash-a", SegmentType: SegmentTypeWEBMAudio, ServeManifest: serveDASHManifest, - Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { + Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { // only generate the actual init segment (init_a.webm) // when generating the first segment init := ".init" @@ -310,6 +300,25 @@ func (t StreamType) FileDir(hash string, maxTranscodeSize int) string { } } +func HLSGetCodec(sm *StreamManager, name string) (codec VideoCodec) { + switch name { + case "hls": + codec = VideoCodecLibX264 + if hwcodec := sm.encoder.hwCodecHLSCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { + codec = *hwcodec + } + case "dash-v": + codec = VideoCodecVP9 + if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { + codec = *hwcodec + } + case "hls-copy": + codec = VideoCodecCopy + } + + return codec +} + func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { extraInputArgs := sm.config.GetLiveTranscodeInputArgs() extraOutputArgs := sm.config.GetLiveTranscodeOutputArgs() @@ -317,6 +326,9 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { args := Args{"-hide_banner"} args = args.LogLevel(LogLevelError) + codec := HLSGetCodec(sm, s.streamType.Name) + + args = sm.encoder.hwDeviceInit(args, codec) args = append(args, extraInputArgs...) if segment > 0 { @@ -327,10 +339,9 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported - var videoFilter VideoFilter - videoFilter = videoFilter.ScaleMax(s.vf.Width, s.vf.Height, s.maxTranscodeSize) + videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf.Width, s.vf.Height, s.maxTranscodeSize) - args = append(args, s.streamType.Args(segment, videoFilter, videoOnly, s.outputDir)...) + args = append(args, s.streamType.Args(codec, segment, videoFilter, videoOnly, s.outputDir)...) args = append(args, extraOutputArgs...) diff --git a/pkg/ffmpeg/stream_transcode.go b/pkg/ffmpeg/stream_transcode.go index 209816ad6..7fbfc08a8 100644 --- a/pkg/ffmpeg/stream_transcode.go +++ b/pkg/ffmpeg/stream_transcode.go @@ -16,20 +16,78 @@ import ( type StreamFormat struct { MimeType string - Args func(videoFilter VideoFilter, videoOnly bool) Args + Args func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) Args +} + +func CodecInit(codec VideoCodec) (args Args) { + args = args.VideoCodec(codec) + + switch codec { + // CPU Codecs + case VideoCodecLibX264: + args = append(args, + "-pix_fmt", "yuv420p", + "-preset", "veryfast", + "-crf", "25", + "-sc_threshold", "0", + ) + case VideoCodecVP9: + args = append(args, + "-pix_fmt", "yuv420p", + "-deadline", "realtime", + "-cpu-used", "5", + "-row-mt", "1", + "-crf", "30", + "-b:v", "0", + ) + // HW Codecs + case VideoCodecN264: + args = append(args, + "-rc", "vbr", + "-cq", "15", + ) + case VideoCodecI264: + args = append(args, + "-global_quality", "20", + "-preset", "faster", + ) + case VideoCodecV264: + args = append(args, + "-qp", "20", + ) + case VideoCodecA264: + args = append(args, + "-quality", "speed", + ) + case VideoCodecM264: + args = append(args, + "-prio_speed", "1", + ) + case VideoCodecO264: + args = append(args, + "-preset", "superfast", + "-crf", "25", + ) + case VideoCodecIVP9: + args = append(args, + "-global_quality", "20", + "-preset", "faster", + ) + case VideoCodecVVP9: + args = append(args, + "-qp", "20", + ) + } + + return args } var ( StreamTypeMP4 = StreamFormat{ MimeType: MimeMp4Video, - Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) { - args = args.VideoCodec(VideoCodecLibX264) - args = append(args, - "-movflags", "frag_keyframe+empty_moov", - "-pix_fmt", "yuv420p", - "-preset", "veryfast", - "-crf", "25", - ) + Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { + args = CodecInit(codec) + args = append(args, "-movflags", "frag_keyframe+empty_moov") args = args.VideoFilter(videoFilter) if videoOnly { args = args.SkipAudio() @@ -42,16 +100,8 @@ var ( } StreamTypeWEBM = StreamFormat{ MimeType: MimeWebmVideo, - Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) { - args = args.VideoCodec(VideoCodecVP9) - args = append(args, - "-pix_fmt", "yuv420p", - "-deadline", "realtime", - "-cpu-used", "5", - "-row-mt", "1", - "-crf", "30", - "-b:v", "0", - ) + Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { + args = CodecInit(codec) args = args.VideoFilter(videoFilter) if videoOnly { args = args.SkipAudio() @@ -64,8 +114,8 @@ var ( } StreamTypeMKV = StreamFormat{ MimeType: MimeMkvVideo, - Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) { - args = args.VideoCodec(VideoCodecCopy) + Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { + args = CodecInit(codec) if videoOnly { args = args.SkipAudio() } else { @@ -89,6 +139,25 @@ type TranscodeOptions struct { StartTime float64 } +func FileGetCodec(sm *StreamManager, mimetype string) (codec VideoCodec) { + switch mimetype { + case MimeMp4Video: + codec = VideoCodecLibX264 + if hwcodec := sm.encoder.hwCodecMP4Compatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { + codec = *hwcodec + } + case MimeWebmVideo: + codec = VideoCodecVP9 + if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { + codec = *hwcodec + } + case MimeMkvVideo: + codec = VideoCodecCopy + } + + return codec +} + func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution() if o.Resolution != "" { @@ -100,6 +169,9 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { args := Args{"-hide_banner"} args = args.LogLevel(LogLevelError) + codec := FileGetCodec(sm, o.StreamType.MimeType) + + args = sm.encoder.hwDeviceInit(args, codec) args = append(args, extraInputArgs...) if o.StartTime != 0 { @@ -110,10 +182,9 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported - var videoFilter VideoFilter - videoFilter = videoFilter.ScaleMax(o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize) + videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize) - args = append(args, o.StreamType.Args(videoFilter, videoOnly)...) + args = append(args, o.StreamType.Args(codec, videoFilter, videoOnly)...) args = append(args, extraOutputArgs...) diff --git a/pkg/hash/videophash/phash.go b/pkg/hash/videophash/phash.go index 8438d9553..0cbefc2ae 100644 --- a/pkg/hash/videophash/phash.go +++ b/pkg/hash/videophash/phash.go @@ -23,7 +23,7 @@ const ( rows = 5 ) -func Generate(encoder ffmpeg.FFMpeg, videoFile *file.VideoFile) (*uint64, error) { +func Generate(encoder *ffmpeg.FFMpeg, videoFile *file.VideoFile) (*uint64, error) { sprite, err := generateSprite(encoder, videoFile) if err != nil { return nil, err @@ -37,7 +37,7 @@ func Generate(encoder ffmpeg.FFMpeg, videoFile *file.VideoFile) (*uint64, error) return &hashValue, nil } -func generateSpriteScreenshot(encoder ffmpeg.FFMpeg, input string, t float64) (image.Image, error) { +func generateSpriteScreenshot(encoder *ffmpeg.FFMpeg, input string, t float64) (image.Image, error) { options := transcoder.ScreenshotOptions{ Width: screenshotSize, OutputPath: "-", @@ -76,7 +76,7 @@ func combineImages(images []image.Image) image.Image { return montage } -func generateSprite(encoder ffmpeg.FFMpeg, videoFile *file.VideoFile) (image.Image, error) { +func generateSprite(encoder *ffmpeg.FFMpeg, videoFile *file.VideoFile) (image.Image, error) { logger.Infof("[generator] generating phash sprite for %s", videoFile.Path) // Generate sprite image offset by 5% on each end to avoid intro/outros diff --git a/pkg/image/thumbnail.go b/pkg/image/thumbnail.go index 9fc720a76..80c2139cc 100644 --- a/pkg/image/thumbnail.go +++ b/pkg/image/thumbnail.go @@ -32,7 +32,7 @@ type ThumbnailGenerator interface { } type ThumbnailEncoder struct { - ffmpeg ffmpeg.FFMpeg + ffmpeg *ffmpeg.FFMpeg vips *vipsEncoder } @@ -43,7 +43,7 @@ func GetVipsPath() string { return vipsPath } -func NewThumbnailEncoder(ffmpegEncoder ffmpeg.FFMpeg) ThumbnailEncoder { +func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder { ret := ThumbnailEncoder{ ffmpeg: ffmpegEncoder, } diff --git a/pkg/scene/generate/generator.go b/pkg/scene/generate/generator.go index 7243ca0ff..000082414 100644 --- a/pkg/scene/generate/generator.go +++ b/pkg/scene/generate/generator.go @@ -53,7 +53,7 @@ type FFMpegConfig interface { } type Generator struct { - Encoder ffmpeg.FFMpeg + Encoder *ffmpeg.FFMpeg FFMpegConfig FFMpegConfig LockManager *fsutil.ReadLockManager MarkerPaths MarkerPaths diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index b23f72027..bb6d5131f 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -228,6 +228,14 @@ export const SettingsConfigurationPanel: React.FC = () => { ))} + saveGeneral({ transcodeHardwareAcceleration: v })} + /> +