diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 5ab7fdfea..518fb6ac1 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -109,6 +109,8 @@ input ConfigGeneralInput { previewPreset: PreviewPreset "Transcode Hardware Acceleration" transcodeHardwareAcceleration: Boolean + "Generation Hardware Acceleration" + generationHardwareAcceleration: Boolean "Max generated transcode size" maxTranscodeSize: StreamingResolutionEnum "Max streaming transcode size" @@ -247,6 +249,8 @@ type ConfigGeneralResult { previewPreset: PreviewPreset! "Transcode Hardware Acceleration" transcodeHardwareAcceleration: Boolean! + "Generation Hardware Acceleration" + generationHardwareAcceleration: 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 3df1c9114..725f43c56 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -301,6 +301,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen r.setConfigInt(config.SpriteScreenshotSize, input.SpriteScreenshotSize) r.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration) + r.setConfigBool(config.GenerationHardwareAcceleration, input.GenerationHardwareAcceleration) if input.MaxTranscodeSize != nil { c.SetString(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index cf2c0e3cc..7f5a9098c 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -79,65 +79,66 @@ func makeConfigGeneralResult() *ConfigGeneralResult { customPerformerImageLocation := config.GetCustomPerformerImageLocation() return &ConfigGeneralResult{ - Stashes: config.GetStashPaths(), - DatabasePath: config.GetDatabasePath(), - BackupDirectoryPath: config.GetBackupDirectoryPath(), - DeleteTrashPath: config.GetDeleteTrashPath(), - GeneratedPath: config.GetGeneratedPath(), - MetadataPath: config.GetMetadataPath(), - ConfigFilePath: config.GetConfigFile(), - ScrapersPath: config.GetScrapersPath(), - PluginsPath: config.GetPluginsPath(), - CachePath: config.GetCachePath(), - BlobsPath: config.GetBlobsPath(), - BlobsStorage: config.GetBlobsStorage(), - FfmpegPath: config.GetFFMpegPath(), - FfprobePath: config.GetFFProbePath(), - CalculateMd5: config.IsCalculateMD5(), - VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), - ParallelTasks: config.GetParallelTasks(), - UseCustomSpriteInterval: config.GetUseCustomSpriteInterval(), - SpriteInterval: config.GetSpriteInterval(), - SpriteScreenshotSize: config.GetSpriteScreenshotSize(), - MinimumSprites: config.GetMinimumSprites(), - MaximumSprites: config.GetMaximumSprites(), - 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(), - CreateImageClipsFromVideos: config.IsCreateImageClipsFromVideos(), - 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(), - LogFileMaxSize: config.GetLogFileMaxSize(), - VideoExtensions: config.GetVideoExtensions(), - ImageExtensions: config.GetImageExtensions(), - GalleryExtensions: config.GetGalleryExtensions(), - CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(), - Excludes: config.GetExcludes(), - ImageExcludes: config.GetImageExcludes(), - CustomPerformerImageLocation: &customPerformerImageLocation, - StashBoxes: config.GetStashBoxes(), - PythonPath: config.GetPythonPath(), - TranscodeInputArgs: config.GetTranscodeInputArgs(), - TranscodeOutputArgs: config.GetTranscodeOutputArgs(), - LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(), - LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(), - DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(), - ScraperPackageSources: config.GetScraperPackageSources(), - PluginPackageSources: config.GetPluginPackageSources(), + Stashes: config.GetStashPaths(), + DatabasePath: config.GetDatabasePath(), + BackupDirectoryPath: config.GetBackupDirectoryPath(), + DeleteTrashPath: config.GetDeleteTrashPath(), + GeneratedPath: config.GetGeneratedPath(), + MetadataPath: config.GetMetadataPath(), + ConfigFilePath: config.GetConfigFile(), + ScrapersPath: config.GetScrapersPath(), + PluginsPath: config.GetPluginsPath(), + CachePath: config.GetCachePath(), + BlobsPath: config.GetBlobsPath(), + BlobsStorage: config.GetBlobsStorage(), + FfmpegPath: config.GetFFMpegPath(), + FfprobePath: config.GetFFProbePath(), + CalculateMd5: config.IsCalculateMD5(), + VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), + ParallelTasks: config.GetParallelTasks(), + UseCustomSpriteInterval: config.GetUseCustomSpriteInterval(), + SpriteInterval: config.GetSpriteInterval(), + SpriteScreenshotSize: config.GetSpriteScreenshotSize(), + MinimumSprites: config.GetMinimumSprites(), + MaximumSprites: config.GetMaximumSprites(), + PreviewAudio: config.GetPreviewAudio(), + PreviewSegments: config.GetPreviewSegments(), + PreviewSegmentDuration: config.GetPreviewSegmentDuration(), + PreviewExcludeStart: config.GetPreviewExcludeStart(), + PreviewExcludeEnd: config.GetPreviewExcludeEnd(), + PreviewPreset: config.GetPreviewPreset(), + TranscodeHardwareAcceleration: config.GetTranscodeHardwareAcceleration(), + GenerationHardwareAcceleration: config.GetGenerationHardwareAcceleration(), + MaxTranscodeSize: &maxTranscodeSize, + MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, + WriteImageThumbnails: config.IsWriteImageThumbnails(), + CreateImageClipsFromVideos: config.IsCreateImageClipsFromVideos(), + 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(), + LogFileMaxSize: config.GetLogFileMaxSize(), + VideoExtensions: config.GetVideoExtensions(), + ImageExtensions: config.GetImageExtensions(), + GalleryExtensions: config.GetGalleryExtensions(), + CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(), + Excludes: config.GetExcludes(), + ImageExcludes: config.GetImageExcludes(), + CustomPerformerImageLocation: &customPerformerImageLocation, + StashBoxes: config.GetStashBoxes(), + PythonPath: config.GetPythonPath(), + TranscodeInputArgs: config.GetTranscodeInputArgs(), + TranscodeOutputArgs: config.GetTranscodeOutputArgs(), + LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(), + LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(), + DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(), + ScraperPackageSources: config.GetScraperPackageSources(), + PluginPackageSources: config.GetPluginPackageSources(), } } diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 598a6fe26..c31368dd7 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -81,6 +81,7 @@ func (rs imageRoutes) serveThumbnail(w http.ResponseWriter, r *http.Request, img InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(), OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(), Preset: manager.GetInstance().Config.GetPreviewPreset().String(), + HWAccel: manager.GetInstance().Config.GetGenerationHardwareAcceleration(), } encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMpeg, manager.GetInstance().FFProbe, clipPreviewOptions) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 19e263810..3674d9852 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -98,8 +98,9 @@ const ( SpriteScreenshotSize = "sprite_screenshot_width" spriteScreenshotSizeDefault = 160 - PreviewPreset = "preview_preset" - TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration" + PreviewPreset = "preview_preset" + TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration" + GenerationHardwareAcceleration = "ffmpeg.hardware_acceleration_generate" SequentialScanning = "sequential_scanning" SequentialScanningDefault = false @@ -1079,6 +1080,10 @@ func (i *Config) GetTranscodeHardwareAcceleration() bool { return i.getBool(TranscodeHardwareAcceleration) } +func (i *Config) GetGenerationHardwareAcceleration() bool { + return i.getBool(GenerationHardwareAcceleration) +} + func (i *Config) GetMaxTranscodeSize() models.StreamingResolutionEnum { ret := i.getString(MaxTranscodeSize) diff --git a/internal/manager/task_generate_clip_preview.go b/internal/manager/task_generate_clip_preview.go index f3b9d4c13..c55d5ee57 100644 --- a/internal/manager/task_generate_clip_preview.go +++ b/internal/manager/task_generate_clip_preview.go @@ -31,6 +31,7 @@ func (t *GenerateClipPreviewTask) Start(ctx context.Context) { InputArgs: GetInstance().Config.GetTranscodeInputArgs(), OutputArgs: GetInstance().Config.GetTranscodeOutputArgs(), Preset: GetInstance().Config.GetPreviewPreset().String(), + HWAccel: GetInstance().Config.GetGenerationHardwareAcceleration(), } encoder := image.NewThumbnailEncoder(GetInstance().FFMpeg, GetInstance().FFProbe, clipPreviewOptions) diff --git a/internal/manager/task_generate_image_thumbnail.go b/internal/manager/task_generate_image_thumbnail.go index 14518d2bb..1aa310fc2 100644 --- a/internal/manager/task_generate_image_thumbnail.go +++ b/internal/manager/task_generate_image_thumbnail.go @@ -46,6 +46,7 @@ func (t *GenerateImageThumbnailTask) Start(ctx context.Context) { InputArgs: c.GetTranscodeInputArgs(), OutputArgs: c.GetTranscodeOutputArgs(), Preset: c.GetPreviewPreset().String(), + HWAccel: c.GetGenerationHardwareAcceleration(), } encoder := image.NewThumbnailEncoder(mgr.FFMpeg, mgr.FFProbe, clipPreviewOptions) diff --git a/internal/manager/task_generate_markers.go b/internal/manager/task_generate_markers.go index 1da458ba8..40297b925 100644 --- a/internal/manager/task_generate_markers.go +++ b/internal/manager/task_generate_markers.go @@ -5,12 +5,15 @@ import ( "fmt" "path/filepath" + "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene/generate" ) +const markerPreviewTargetWidth = 640 + type GenerateMarkersTask struct { repository models.Repository Scene *models.Scene @@ -66,10 +69,20 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) { return } - t.generateMarker(videoFile, scene, t.Marker) + codec, fullhw := t.determineMarkerCodec(ctx, videoFile) + + t.generateMarker(videoFile, scene, t.Marker, codec, fullhw) } } +func (t *GenerateMarkersTask) determineMarkerCodec(ctx context.Context, videoFile *models.VideoFile) (ffmpeg.VideoCodec, bool) { + if !t.VideoPreview { + return ffmpeg.VideoCodecLibX264, false + } + targetHeight := ffmpeg.ScaledHeight(videoFile.Width, videoFile.Height, markerPreviewTargetWidth) + return t.generator.DetermineCodecAndHW(ctx, videoFile.Path, videoFile.Width, videoFile.Height, targetHeight) +} + func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) { var sceneMarkers []*models.SceneMarker r := t.repository @@ -88,6 +101,7 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) { return } + codec, fullhw := t.determineMarkerCodec(ctx, videoFile) sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) // Make the folder for the scenes markers @@ -100,11 +114,11 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) { index := i + 1 logger.Progressf("[generator] <%s> scene marker %d of %d", sceneHash, index, len(sceneMarkers)) - t.generateMarker(videoFile, t.Scene, sceneMarker) + t.generateMarker(videoFile, t.Scene, sceneMarker, codec, fullhw) } } -func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) { +func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker, codec ffmpeg.VideoCodec, fullhw bool) { sceneHash := scene.GetHash(t.fileNamingAlgorithm) seconds := float64(sceneMarker.Seconds) @@ -117,7 +131,7 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene g := t.generator if t.VideoPreview { - if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil { + if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, videoFile.Width, videoFile.Height, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio(), codec, fullhw); err != nil { logger.Errorf("[generator] failed to generate marker video: %v", err) logErrorOutput(err) } diff --git a/internal/manager/task_generate_preview.go b/internal/manager/task_generate_preview.go index df2a69ee5..5af5c87cd 100644 --- a/internal/manager/task_generate_preview.go +++ b/internal/manager/task_generate_preview.go @@ -40,7 +40,7 @@ func (t *GeneratePreviewTask) Start(ctx context.Context) { return } - if err := t.generateVideo(videoChecksum, videoFile.VideoStreamDuration, videoFile.FrameRate); err != nil { + if err := t.generateVideo(videoChecksum, videoFile.Path, videoFile.Width, videoFile.Height, videoFile.VideoStreamDuration, videoFile.FrameRate); err != nil { logger.Errorf("error generating preview: %v", err) logErrorOutput(err) return @@ -55,18 +55,17 @@ func (t *GeneratePreviewTask) Start(ctx context.Context) { } } -func (t *GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64, videoFrameRate float64) error { - videoFilename := t.Scene.Path +func (t *GeneratePreviewTask) generateVideo(videoChecksum string, path string, width, height int, duration float64, frameRate float64) error { useVsync2 := false - if videoFrameRate <= 0.01 { - logger.Errorf("[generator] Video framerate very low/high (%f) most likely vfr so using -vsync 2", videoFrameRate) + if frameRate <= 0.01 { + logger.Errorf("[generator] Video framerate very low/high (%f) most likely vfr so using -vsync 2", frameRate) useVsync2 = true } - if err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, false, useVsync2); err != nil { + if err := t.generator.PreviewVideo(context.TODO(), path, width, height, duration, videoChecksum, t.Options, false, useVsync2); err != nil { logger.Warnf("[generator] failed generating scene preview, trying fallback") - if err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, true, useVsync2); err != nil { + if err := t.generator.PreviewVideo(context.TODO(), path, width, height, duration, videoChecksum, t.Options, true, useVsync2); err != nil { return err } } diff --git a/internal/manager/task_transcode.go b/internal/manager/task_transcode.go index 2897cd2b9..ad066594a 100644 --- a/internal/manager/task_transcode.go +++ b/internal/manager/task_transcode.go @@ -70,7 +70,9 @@ func (t *GenerateTranscodeTask) Start(ctx context.Context) { sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) transcodeSize := config.GetInstance().GetMaxTranscodeSize() - w, h := videoFile.TranscodeScale(transcodeSize.GetMaxResolution()) + maxResolution := transcodeSize.GetMaxResolution() + + w, h := videoFile.TranscodeScale(maxResolution) // if scale is being set, then we can't use stream copy scaleSet := w == 0 && h == 0 @@ -83,15 +85,14 @@ func (t *GenerateTranscodeTask) Start(ctx context.Context) { } } else { options := generate.TranscodeOptions{ - Width: w, - Height: h, + MaxSize: maxResolution, } if audioCodec == ffmpeg.MissingUnsupported { // ffmpeg fails if it tries to transcode an unsupported audio codec - err = t.g.TranscodeVideo(ctx, videoFile.Path, sceneHash, options) + err = t.g.TranscodeVideo(ctx, videoFile.Path, videoFile.Width, videoFile.Height, sceneHash, options) } else { - err = t.g.Transcode(ctx, videoFile.Path, sceneHash, options) + err = t.g.Transcode(ctx, videoFile.Path, videoFile.Width, videoFile.Height, sceneHash, options) } } diff --git a/pkg/ffmpeg/codec.go b/pkg/ffmpeg/codec.go index 45fff9ffb..d7c028841 100644 --- a/pkg/ffmpeg/codec.go +++ b/pkg/ffmpeg/codec.go @@ -1,5 +1,7 @@ package ffmpeg +import "strconv" + type VideoCodec struct { Name string // The full name of the codec including profile/quality CodeName string // The core codec name without profile/quality suffix @@ -44,3 +46,148 @@ var ( AudioCodecLibOpus AudioCodec = "libopus" AudioCodecCopy AudioCodec = "copy" ) + +func (c VideoCodec) ExtraArgs() (args Args) { + switch c { + // 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 VideoCodecN264H: + args = append(args, + "-tune", "hq", + "-profile", "high", + "-rc", "vbr", + "-rc-lookahead", "60", + "-surfaces", "64", + "-spatial-aq", "1", + "-aq-strength", "15", + "-cq", "15", + "-coder", "cabac", + "-b_ref_mode", "middle", + ) + case VideoCodecI264, VideoCodecIVP9: + args = append(args, + "-global_quality", "20", + "-preset", "faster", + ) + case VideoCodecI264C: + args = append(args, + "-q", "20", + "-preset", "faster", + ) + case VideoCodecV264, VideoCodecVVP9: + args = append(args, + "-qp", "20", + ) + case VideoCodecA264: + args = append(args, + "-quality", "speed", + ) + case VideoCodecM264: + args = append(args, + "-realtime", "1", + ) + case VideoCodecO264: + args = append(args, + "-preset", "superfast", + "-crf", "25", + ) + } + + return args +} + +func (c VideoCodec) ExtraArgsHQ(preset string, crf int) (args Args) { + switch c { + // CPU Codecs + case VideoCodecLibX264: + args = append(args, + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.2", + "-preset", preset, + "-crf", strconv.Itoa(crf), + "-sc_threshold", "0", + "-threads", "4", + "-sws_flags", "lanczos", + ) + case VideoCodecVP9: + args = append(args, + "-pix_fmt", "yuv420p", + "-deadline", "good", + "-cpu-used", "0", + "-row-mt", "1", + "-crf", strconv.Itoa(crf), + "-b:v", "0", + "-threads", "4", + ) + // HW Codecs - fixed HQ quality. + case VideoCodecN264: + args = append(args, + "-rc", "vbr", + "-cq", "10", + ) + case VideoCodecN264H: + args = append(args, + "-tune", "hq", + "-profile", "high", + "-rc", "vbr", + "-rc-lookahead", "60", + "-surfaces", "64", + "-spatial-aq", "1", + "-aq-strength", "15", + "-cq", "10", + "-coder", "cabac", + "-b_ref_mode", "middle", + ) + case VideoCodecI264, VideoCodecIVP9: + args = append(args, + "-global_quality", "15", + "-preset", "slow", + ) + case VideoCodecI264C: + args = append(args, + "-q", "15", + "-preset", "slow", + ) + case VideoCodecV264, VideoCodecVVP9: + args = append(args, + "-qp", "15", + ) + case VideoCodecA264: + args = append(args, + "-quality", "quality", + ) + case VideoCodecM264: + args = append(args, + "-realtime", "0", + ) + case VideoCodecO264: + args = append(args, + "-preset", "slow", + "-crf", strconv.Itoa(crf), + ) + } + + return args +} diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index a83830c52..fff22c008 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -12,7 +12,6 @@ import ( "time" "github.com/stashapp/stash/pkg/logger" - "github.com/stashapp/stash/pkg/models" ) var ( @@ -80,15 +79,16 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) { var args Args args = append(args, "-hide_banner") args = args.LogLevel(LogLevelWarning) - args = f.hwDeviceInit(args, codec, false) + args = f.HWDeviceInit(args, codec, false) args = args.Format("lavfi") - vFile := &models.VideoFile{Width: 1280, Height: 720} - args = args.Input(fmt.Sprintf("color=c=red:s=%dx%d", vFile.Width, vFile.Height)) + width, height := 1280, 720 + args = args.Input(fmt.Sprintf("color=c=red:s=%dx%d", width, height)) args = args.Duration(0.1) // Test scaling - videoFilter := f.hwMaxResFilter(codec, vFile, minHeight, false) - args = append(args, CodecInit(codec)...) + videoFilter := f.HWMaxResFilter(codec, width, height, minHeight, false) + args = args.VideoCodec(codec) + args = append(args, codec.ExtraArgs()...) args = args.VideoFilter(videoFilter) args = args.Format("null") @@ -144,7 +144,7 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) { f.hwCodecSupport = hwCodecSupport } -func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf *models.VideoFile, reqHeight int) bool { +func (f *FFMpeg) HWCanFullHWTranscode(ctx context.Context, codec VideoCodec, path string, width int, height int, reqHeight int) bool { if codec == VideoCodecCopy { return false } @@ -153,12 +153,13 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf args = append(args, "-hide_banner") args = args.LogLevel(LogLevelWarning) args = args.XError() - args = f.hwDeviceInit(args, codec, true) - args = args.Input(vf.Path) + args = f.HWDeviceInit(args, codec, true) + args = args.Input(path) args = args.Duration(1) - videoFilter := f.hwMaxResFilter(codec, vf, reqHeight, true) - args = append(args, CodecInit(codec)...) + videoFilter := f.HWMaxResFilter(codec, width, height, reqHeight, true) + args = args.VideoCodec(codec) + args = append(args, codec.ExtraArgs()...) args = args.VideoFilter(videoFilter) args = args.Format("null") @@ -176,7 +177,7 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf errOutput = err.Error() } - logger.Debugf("[InitHWSupport] Full hardware transcode for file %s not supported. Error output:\n%s", vf.Basename, errOutput) + logger.Debugf("[InitHWSupport] Full hardware transcode for file %s not supported. Error output:\n%s", path, errOutput) return false } @@ -184,7 +185,7 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf } // Prepend input for hardware encoding only -func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { +func (f *FFMpeg) HWDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { // check for custom /dev/dri device #6435 driDevice := os.Getenv("STASH_HW_DRI_DEVICE") if driDevice == "" { @@ -257,7 +258,7 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { } // Initialise a video filter for HW encoding -func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { +func (f *FFMpeg) HWFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { var videoFilter VideoFilter switch toCodec { case VideoCodecV264, @@ -298,7 +299,7 @@ func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { var scaler_re = regexp.MustCompile(`scale=(?P([-\d]+):([-\d]+))`) -func templateReplaceScale(input string, template string, match []int, vf *models.VideoFile, minusonehack bool) string { +func templateReplaceScale(input string, template string, match []int, width, height int, minusonehack bool) string { result := []byte{} if minusonehack { @@ -315,7 +316,7 @@ func templateReplaceScale(input string, template string, match []int, vf *models } // Calculate ratio - ratio := float64(vf.Width) / float64(vf.Height) + ratio := float64(width) / float64(height) if w < 0 { w = int(math.Round(float64(h) * ratio)) } else if h < 0 { @@ -342,19 +343,19 @@ func templateReplaceScale(input string, template string, match []int, vf *models } // Replace video filter scaling with hardware scaling for full hardware transcoding (also fixes the format) -func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, vf *models.VideoFile, fullhw bool) VideoFilter { +func (f *FFMpeg) HWCodecFilter(args VideoFilter, codec VideoCodec, width, height int, fullhw bool) VideoFilter { sargs := string(args) match := scaler_re.FindStringSubmatchIndex(sargs) if match == nil { - return f.hwApplyFullHWFilter(args, codec, fullhw) + return f.HWApplyFullHWFilter(args, codec, fullhw) } - return f.hwApplyScaleTemplate(sargs, codec, match, vf, fullhw) + return f.HWApplyScaleTemplate(sargs, codec, match, width, height, fullhw) } // Apply format switching if applicable -func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { +func (f *FFMpeg) HWApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { switch codec { case VideoCodecN264, VideoCodecN264H: if fullhw && f.version.Gteq(Version{major: 5}) { // Added in FFMpeg 5 @@ -380,7 +381,7 @@ func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw } // Switch scaler -func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []int, vf *models.VideoFile, fullhw bool) VideoFilter { +func (f *FFMpeg) HWApplyScaleTemplate(sargs string, codec VideoCodec, match []int, width, height int, fullhw bool) VideoFilter { var template string switch codec { @@ -418,11 +419,11 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in // BUG: scale_vt doesn't call ff_scale_adjust_dimensions, thus cant accept negative size values isApple := codec == VideoCodecM264 // Rockchip's scale_rkrga supports -1/-2; don't apply minus-one hack here. - return VideoFilter(templateReplaceScale(sargs, template, match, vf, isIntel || isApple)) + return VideoFilter(templateReplaceScale(sargs, template, match, width, height, isIntel || isApple)) } // Returns the max resolution for a given codec, or a default -func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec) (int, int) { +func (f *FFMpeg) HWCodecMaxRes(codec VideoCodec) (int, int) { switch codec { case VideoCodecRK264: return 8192, 8192 @@ -437,18 +438,18 @@ func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec) (int, int) { } // Return a maxres filter -func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHeight int, fullhw bool) VideoFilter { - if vf.Width == 0 || vf.Height == 0 { +func (f *FFMpeg) HWMaxResFilter(toCodec VideoCodec, width int, height int, reqHeight int, fullhw bool) VideoFilter { + if width == 0 || height == 0 { return "" } - videoFilter := f.hwFilterInit(toCodec, fullhw) - maxWidth, maxHeight := f.hwCodecMaxRes(toCodec) - videoFilter = videoFilter.ScaleMaxLM(vf.Width, vf.Height, reqHeight, maxWidth, maxHeight) - return f.hwCodecFilter(videoFilter, toCodec, vf, fullhw) + videoFilter := f.HWFilterInit(toCodec, fullhw) + maxWidth, maxHeight := f.HWCodecMaxRes(toCodec) + videoFilter = videoFilter.ScaleMax(width, height, reqHeight, maxWidth, maxHeight) + return f.HWCodecFilter(videoFilter, toCodec, width, height, fullhw) } -// Return if a hardware accelerated for HLS is available -func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec { +// Return a hardware accelerated codec for HLS if available, otherwise the default +func (f *FFMpeg) HWCodecHLSCompatible(defaultCodec VideoCodec) VideoCodec { for _, element := range f.getHWCodecSupport() { switch element { case VideoCodecN264, @@ -459,14 +460,14 @@ func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec { VideoCodecR264, VideoCodecM264, // Note that the Apple encoder sucks at startup, thus HLS quality is crap VideoCodecRK264: - return &element + return element } } - return nil + return defaultCodec } -// Return if a hardware accelerated codec for MP4 is available -func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec { +// Return a hardware accelerated codec for MP4 if available, otherwise the default +func (f *FFMpeg) HWCodecMP4Compatible(defaultCodec VideoCodec) VideoCodec { for _, element := range f.getHWCodecSupport() { switch element { case VideoCodecN264, @@ -475,20 +476,20 @@ func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec { VideoCodecI264C, VideoCodecM264, VideoCodecRK264: - return &element + return element } } - return nil + return defaultCodec } -// Return if a hardware accelerated codec for WebM is available -func (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec { +// Return a hardware accelerated codec for WebM if available, otherwise the default +func (f *FFMpeg) HWCodecWEBMCompatible(defaultCodec VideoCodec) VideoCodec { for _, element := range f.getHWCodecSupport() { switch element { case VideoCodecIVP9, VideoCodecVVP9: - return &element + return element } } - return nil + return defaultCodec } diff --git a/pkg/ffmpeg/filter.go b/pkg/ffmpeg/filter.go index dd6ecc106..954b57288 100644 --- a/pkg/ffmpeg/filter.go +++ b/pkg/ffmpeg/filter.go @@ -36,51 +36,44 @@ func (f VideoFilter) ScaleMaxSize(maxDimensions int) VideoFilter { return f.Append(fmt.Sprintf("scale=%v:%v:force_original_aspect_ratio=decrease", maxDimensions, maxDimensions)) } -// ScaleMax returns a VideoFilter scaling to maxSize. It will scale width if it is larger than height, otherwise it will scale height. -func (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter { - // get the smaller dimension of the input - videoSize := inputHeight - if inputWidth < videoSize { - videoSize = inputWidth +// ScaledHeight returns the height a source would have if scaled to targetWidth +// with preserved aspect ratio, rounded down to an even value. +func ScaledHeight(width, height, targetWidth int) int { + return (targetWidth * height / width) &^ 1 +} + +// ScaleMax scales to reqHeight (0 = source height), optionally clamped to a +// maxWidth x maxHeight rect. Aspect ratio is preserved. +func (f VideoFilter) ScaleMax(width, height, reqHeight, maxWidth, maxHeight int) VideoFilter { + // if a rect is given, clamp to whichever edge overshoots it by more + if maxWidth > 0 && maxHeight > 0 { + // projected dimensions at reqHeight (or source height if 0) + target := reqHeight + if target == 0 { + target = height + } + projectedWidth := target * width / height + + if target > maxHeight || projectedWidth > maxWidth { + // cap the edge that exceeds its limit by more + if target-maxHeight > projectedWidth-maxWidth { + return f.ScaleDimensions(-2, maxHeight) + } + return f.ScaleDimensions(maxWidth, -2) + } } - // if maxSize is larger than the video dimension, then no-op - if maxSize >= videoSize || maxSize == 0 { + // no-op if reqHeight is larger than the smaller dimension + if reqHeight == 0 || reqHeight >= min(width, height) { return f } - // we're setting either the width or height - // we'll set the smaller dimesion - if inputWidth > inputHeight { + // scale the smaller dimension to reqHeight + if width > height { // set the height - return f.ScaleDimensions(-2, maxSize) - } - - return f.ScaleDimensions(maxSize, -2) -} - -// ScaleMaxLM scales an image to fit within specified maximum dimensions while maintaining its aspect ratio. -func (f VideoFilter) ScaleMaxLM(width int, height int, reqHeight int, maxWidth int, maxHeight int) VideoFilter { - if maxWidth == 0 || maxHeight == 0 { - return f.ScaleMax(width, height, reqHeight) - } - - aspectRatio := float64(width) / float64(height) - desiredHeight := reqHeight - if desiredHeight == 0 { - desiredHeight = height - } - desiredWidth := int(float64(desiredHeight) * aspectRatio) - - if desiredHeight <= maxHeight && desiredWidth <= maxWidth { - return f.ScaleMax(width, height, reqHeight) - } - - if float64(desiredHeight-maxHeight) > float64(desiredWidth-maxWidth) { - return f.ScaleDimensions(-2, maxHeight) - } else { - return f.ScaleDimensions(maxWidth, -2) + return f.ScaleDimensions(-2, reqHeight) } + return f.ScaleDimensions(reqHeight, -2) } // Fps returns a VideoFilter setting the frames per second. diff --git a/pkg/ffmpeg/stream_segmented.go b/pkg/ffmpeg/stream_segmented.go index f35b960ab..85c95522a 100644 --- a/pkg/ffmpeg/stream_segmented.go +++ b/pkg/ffmpeg/stream_segmented.go @@ -65,7 +65,8 @@ var ( SegmentType: SegmentTypeTS, ServeManifest: serveHLSManifest, Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { - args = CodecInit(codec) + args = args.VideoCodec(codec) + args = append(args, codec.ExtraArgs()...) args = append(args, "-flags", "+cgop", "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength), @@ -100,7 +101,8 @@ var ( SegmentType: SegmentTypeTS, ServeManifest: serveHLSManifest, Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { - args = CodecInit(codec) + args = args.VideoCodec(codec) + args = append(args, codec.ExtraArgs()...) if videoOnly { args = append(args, "-an") } else { @@ -137,7 +139,8 @@ var ( init = "init" } - args = CodecInit(codec) + args = args.VideoCodec(codec) + args = append(args, codec.ExtraArgs()...) args = append(args, "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength), ) @@ -311,13 +314,13 @@ 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 + if sm.config.GetTranscodeHardwareAcceleration() { + codec = sm.encoder.HWCodecHLSCompatible(VideoCodecLibX264) } case "dash-v": codec = VideoCodecVP9 - if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { - codec = *hwcodec + if sm.config.GetTranscodeHardwareAcceleration() { + codec = sm.encoder.HWCodecWEBMCompatible(VideoCodecVP9) } case "hls-copy": codec = VideoCodecCopy @@ -335,8 +338,8 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { codec := HLSGetCodec(sm, s.streamType.Name) - fullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.hwCanFullHWTranscode(sm.context, codec, s.vf, s.maxTranscodeSize) - args = sm.encoder.hwDeviceInit(args, codec, fullhw) + fullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.HWCanFullHWTranscode(sm.context, codec, s.vf.Path, s.vf.Width, s.vf.Height, s.maxTranscodeSize) + args = sm.encoder.HWDeviceInit(args, codec, fullhw) args = append(args, extraInputArgs...) if segment > 0 { @@ -347,7 +350,7 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported - videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf, s.maxTranscodeSize, fullhw) + videoFilter := sm.encoder.HWMaxResFilter(codec, s.vf.Width, s.vf.Height, s.maxTranscodeSize, fullhw) args = append(args, s.streamType.Args(codec, segment, videoFilter, videoOnly, s.outputDir)...) diff --git a/pkg/ffmpeg/stream_transcode.go b/pkg/ffmpeg/stream_transcode.go index bb701664f..41df28319 100644 --- a/pkg/ffmpeg/stream_transcode.go +++ b/pkg/ffmpeg/stream_transcode.go @@ -19,84 +19,12 @@ type StreamFormat struct { 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 VideoCodecN264H: - args = append(args, - "-profile", "p7", - "-tune", "hq", - "-profile", "high", - "-rc", "vbr", - "-rc-lookahead", "60", - "-surfaces", "64", - "-spatial-aq", "1", - "-aq-strength", "15", - "-cq", "15", - "-coder", "cabac", - "-b_ref_mode", "middle", - ) - case VideoCodecI264, VideoCodecIVP9: - args = append(args, - "-global_quality", "20", - "-preset", "faster", - ) - case VideoCodecI264C: - args = append(args, - "-q", "20", - "-preset", "faster", - ) - case VideoCodecV264, VideoCodecVVP9: - args = append(args, - "-qp", "20", - ) - case VideoCodecA264: - args = append(args, - "-quality", "speed", - ) - case VideoCodecM264: - args = append(args, - "-realtime", "1", - ) - case VideoCodecO264: - args = append(args, - "-preset", "superfast", - "-crf", "25", - ) - } - - return args -} - var ( StreamTypeMP4 = StreamFormat{ MimeType: MimeMp4Video, Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { - args = CodecInit(codec) + args = args.VideoCodec(codec) + args = append(args, codec.ExtraArgs()...) args = append(args, "-movflags", "frag_keyframe+empty_moov") args = args.VideoFilter(videoFilter) if videoOnly { @@ -111,7 +39,8 @@ var ( StreamTypeWEBM = StreamFormat{ MimeType: MimeWebmVideo, Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { - args = CodecInit(codec) + args = args.VideoCodec(codec) + args = append(args, codec.ExtraArgs()...) args = args.VideoFilter(videoFilter) if videoOnly { args = args.SkipAudio() @@ -125,7 +54,8 @@ var ( StreamTypeMKV = StreamFormat{ MimeType: MimeMkvVideo, Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) { - args = CodecInit(codec) + args = args.VideoCodec(codec) + args = append(args, codec.ExtraArgs()...) if videoOnly { args = args.SkipAudio() } else { @@ -166,16 +96,16 @@ func (o TranscodeOptions) FileGetCodec(sm *StreamManager, maxTranscodeSize int) return VideoCodecCopy } codec = VideoCodecLibX264 - if hwcodec := sm.encoder.hwCodecMP4Compatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { - codec = *hwcodec + if sm.config.GetTranscodeHardwareAcceleration() { + codec = sm.encoder.HWCodecMP4Compatible(VideoCodecLibX264) } case MimeWebmVideo: if !needsResize && (o.VideoFile.VideoCodec == Vp8 || o.VideoFile.VideoCodec == Vp9) { return VideoCodecCopy } codec = VideoCodecVP9 - if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() { - codec = *hwcodec + if sm.config.GetTranscodeHardwareAcceleration() { + codec = sm.encoder.HWCodecWEBMCompatible(VideoCodecVP9) } case MimeMkvVideo: codec = VideoCodecCopy @@ -197,8 +127,8 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { codec := o.FileGetCodec(sm, maxTranscodeSize) - fullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.hwCanFullHWTranscode(sm.context, codec, o.VideoFile, maxTranscodeSize) - args = sm.encoder.hwDeviceInit(args, codec, fullhw) + fullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.HWCanFullHWTranscode(sm.context, codec, o.VideoFile.Path, o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize) + args = sm.encoder.HWDeviceInit(args, codec, fullhw) args = append(args, extraInputArgs...) if o.StartTime != 0 { @@ -209,7 +139,7 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported - videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile, maxTranscodeSize, fullhw) + videoFilter := sm.encoder.HWMaxResFilter(codec, o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize, fullhw) args = append(args, o.StreamType.Args(codec, videoFilter, videoOnly)...) diff --git a/pkg/image/thumbnail.go b/pkg/image/thumbnail.go index d0fba0f60..0adf168fc 100644 --- a/pkg/image/thumbnail.go +++ b/pkg/image/thumbnail.go @@ -17,7 +17,10 @@ import ( "github.com/stashapp/stash/pkg/models" ) -const ffmpegImageQuality = 5 +const ( + ffmpegImageQuality = 5 + clipPreviewCRF = 25 +) var vipsPath string var once sync.Once @@ -36,6 +39,7 @@ type ClipPreviewOptions struct { InputArgs []string OutputArgs []string Preset string + HWAccel bool } func GetVipsPath() string { @@ -140,7 +144,7 @@ func (e *ThumbnailEncoder) GetPreview(inPath string, outPath string, maxSize int if clipDuration > 30.0 { clipDuration = 30.0 } - return e.getClipPreview(inPath, outPath, maxSize, clipDuration, fileData.FrameRate) + return e.getClipPreview(inPath, outPath, maxSize, clipDuration, fileData.FrameRate, fileData) } func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) { @@ -170,21 +174,24 @@ func (e *ThumbnailEncoder) ffmpegImageThumbnailPath(inputPath string, maxSize in return e.FFMpeg.GenerateOutput(context.TODO(), args, nil) } -func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize int, clipDuration float64, frameRate float64) error { - var thumbFilter ffmpeg.VideoFilter - thumbFilter = thumbFilter.ScaleMaxSize(maxSize) +func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize int, clipDuration float64, frameRate float64, fileData *ffmpeg.VideoFile) error { + codec := ffmpeg.VideoCodecVP9 + if e.ClipPreviewOptions.HWAccel { + codec = e.FFMpeg.HWCodecWEBMCompatible(codec) + } - var thumbArgs ffmpeg.Args - thumbArgs = thumbArgs.VideoFilter(thumbFilter) + var thumbFilter ffmpeg.VideoFilter + // TODO: switching this to a HW-compatible scale filter would let us enable + // full hardware encoding here. ScaleMaxSize uses force_original_aspect_ratio + // which the HW scaler templates don't handle, so we fall back to non-fullhw. + thumbFilter = thumbFilter.ScaleMaxSize(maxSize) o := e.ClipPreviewOptions + thumbArgs := codec.ExtraArgsHQ(o.Preset, clipPreviewCRF) + thumbArgs = thumbArgs.VideoFilter(thumbFilter) + thumbArgs = append(thumbArgs, - "-pix_fmt", "yuv420p", - "-preset", o.Preset, - "-crf", "25", - "-threads", "4", - "-strict", "-2", "-f", "webm", ) @@ -192,6 +199,8 @@ func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize thumbArgs = append(thumbArgs, "-vsync", "2") } + extraInputArgs := e.FFMpeg.HWDeviceInit(o.InputArgs, codec, false) + thumbOptions := transcoder.TranscodeOptions{ OutputPath: outPath, StartTime: 0, @@ -200,10 +209,10 @@ func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize XError: true, SlowSeek: false, - VideoCodec: ffmpeg.VideoCodecVP9, + VideoCodec: codec, VideoArgs: thumbArgs, - ExtraInputArgs: o.InputArgs, + ExtraInputArgs: extraInputArgs, ExtraOutputArgs: o.OutputArgs, } diff --git a/pkg/scene/generate/generator.go b/pkg/scene/generate/generator.go index 7e5705679..847ceb129 100644 --- a/pkg/scene/generate/generator.go +++ b/pkg/scene/generate/generator.go @@ -3,6 +3,7 @@ package generate import ( "bytes" + "context" "errors" "fmt" "os" @@ -48,6 +49,7 @@ type ScenePaths interface { type FFMpegConfig interface { GetTranscodeInputArgs() []string GetTranscodeOutputArgs() []string + GetGenerationHardwareAcceleration() bool } type Generator struct { @@ -70,6 +72,17 @@ func (g Generator) tempFile(p Paths, pattern string) (*os.File, error) { return tmpFile, err } +func (g *Generator) DetermineCodecAndHW(ctx context.Context, path string, width, height, targetHeight int) (ffmpeg.VideoCodec, bool) { + codec := ffmpeg.VideoCodecLibX264 + fullhw := false + if g.FFMpegConfig.GetGenerationHardwareAcceleration() { + codec = g.Encoder.HWCodecMP4Compatible(codec) + fullhw = codec != ffmpeg.VideoCodecLibX264 && + g.Encoder.HWCanFullHWTranscode(ctx, codec, path, width, height, targetHeight) + } + return codec, fullhw +} + // generateFile performs a generate operation by generating a temporary file using p and pattern, then // moving it to output on success. func (g Generator) generateFile(lockCtx *fsutil.LockContext, p Paths, pattern string, output string, generateFn generateFn) error { diff --git a/pkg/scene/generate/marker_preview.go b/pkg/scene/generate/marker_preview.go index f14613591..60304a10c 100644 --- a/pkg/scene/generate/marker_preview.go +++ b/pkg/scene/generate/marker_preview.go @@ -17,11 +17,12 @@ const ( markerImageDuration = 5 markerWebpFPS = 12 + markerPreviewCRF = 24 markerScreenshotQuality = 2 ) -func (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash string, seconds float64, endSeconds *float64, includeAudio bool) error { - lockCtx := g.LockManager.ReadLock(ctx, input) +func (g Generator) MarkerPreviewVideo(ctx context.Context, path string, width, height int, hash string, seconds float64, endSeconds *float64, includeAudio bool, codec ffmpeg.VideoCodec, fullhw bool) error { + lockCtx := g.LockManager.ReadLock(ctx, path) defer lockCtx.Cancel() output := g.MarkerPaths.GetVideoPreviewPath(hash, int(seconds)) @@ -38,7 +39,7 @@ func (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash st duration = float64(*endSeconds) - seconds } - if err := g.generateFile(lockCtx, g.MarkerPaths, mp4Pattern, output, g.markerPreviewVideo(input, sceneMarkerOptions{ + if err := g.generateFile(lockCtx, g.MarkerPaths, mp4Pattern, output, g.markerPreviewVideo(path, width, height, codec, fullhw, sceneMarkerOptions{ Seconds: seconds, Duration: duration, Audio: includeAudio, @@ -57,32 +58,28 @@ type sceneMarkerOptions struct { Audio bool } -func (g Generator) markerPreviewVideo(input string, options sceneMarkerOptions) generateFn { +func (g Generator) markerPreviewVideo(path string, width, height int, codec ffmpeg.VideoCodec, fullhw bool, options sceneMarkerOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { - var videoFilter ffmpeg.VideoFilter - videoFilter = videoFilter.ScaleWidth(markerPreviewWidth) + targetHeight := ffmpeg.ScaledHeight(width, height, markerPreviewWidth) - var videoArgs ffmpeg.Args + videoFilter := g.Encoder.HWMaxResFilter(codec, width, height, targetHeight, fullhw) + + videoArgs := codec.ExtraArgsHQ("veryslow", markerPreviewCRF) videoArgs = videoArgs.VideoFilter(videoFilter) - videoArgs = append(videoArgs, - "-pix_fmt", "yuv420p", - "-profile:v", "high", - "-level", "4.2", - "-preset", "veryslow", - "-crf", "24", "-movflags", "+faststart", - "-threads", "4", - "-sws_flags", "lanczos", "-strict", "-2", ) + extraInputArgs := g.Encoder.HWDeviceInit(ffmpeg.Args{}, codec, fullhw) + trimOptions := transcoder.TranscodeOptions{ - Duration: options.Duration, - StartTime: options.Seconds, - OutputPath: tmpFn, - VideoCodec: ffmpeg.VideoCodecLibX264, - VideoArgs: videoArgs, + Duration: options.Duration, + StartTime: options.Seconds, + OutputPath: tmpFn, + VideoCodec: codec, + VideoArgs: videoArgs, + ExtraInputArgs: extraInputArgs, } if options.Audio { @@ -93,14 +90,14 @@ func (g Generator) markerPreviewVideo(input string, options sceneMarkerOptions) trimOptions.AudioArgs = audioArgs } - args := transcoder.Transcode(input, trimOptions) + args := transcoder.Transcode(path, trimOptions) return g.generate(lockCtx, args) } } -func (g Generator) SceneMarkerWebp(ctx context.Context, input string, hash string, seconds float64) error { - lockCtx := g.LockManager.ReadLock(ctx, input) +func (g Generator) SceneMarkerWebp(ctx context.Context, path string, hash string, seconds float64) error { + lockCtx := g.LockManager.ReadLock(ctx, path) defer lockCtx.Cancel() output := g.MarkerPaths.GetWebpPreviewPath(hash, int(seconds)) @@ -110,7 +107,7 @@ func (g Generator) SceneMarkerWebp(ctx context.Context, input string, hash strin } } - if err := g.generateFile(lockCtx, g.MarkerPaths, webpPattern, output, g.sceneMarkerWebp(input, sceneMarkerOptions{ + if err := g.generateFile(lockCtx, g.MarkerPaths, webpPattern, output, g.sceneMarkerWebp(path, sceneMarkerOptions{ Seconds: seconds, })); err != nil { return err @@ -121,7 +118,7 @@ func (g Generator) SceneMarkerWebp(ctx context.Context, input string, hash strin return nil } -func (g Generator) sceneMarkerWebp(input string, options sceneMarkerOptions) generateFn { +func (g Generator) sceneMarkerWebp(path string, options sceneMarkerOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { var videoFilter ffmpeg.VideoFilter videoFilter = videoFilter.ScaleWidth(markerPreviewWidth) @@ -146,14 +143,14 @@ func (g Generator) sceneMarkerWebp(input string, options sceneMarkerOptions) gen VideoArgs: videoArgs, } - args := transcoder.Transcode(input, trimOptions) + args := transcoder.Transcode(path, trimOptions) return g.generate(lockCtx, args) } } -func (g Generator) SceneMarkerScreenshot(ctx context.Context, input string, hash string, seconds float64, width int) error { - lockCtx := g.LockManager.ReadLock(ctx, input) +func (g Generator) SceneMarkerScreenshot(ctx context.Context, path string, hash string, seconds float64, width int) error { + lockCtx := g.LockManager.ReadLock(ctx, path) defer lockCtx.Cancel() output := g.MarkerPaths.GetScreenshotPath(hash, int(seconds)) @@ -163,7 +160,7 @@ func (g Generator) SceneMarkerScreenshot(ctx context.Context, input string, hash } } - if err := g.generateFile(lockCtx, g.MarkerPaths, jpgPattern, output, g.sceneMarkerScreenshot(input, SceneMarkerScreenshotOptions{ + if err := g.generateFile(lockCtx, g.MarkerPaths, jpgPattern, output, g.sceneMarkerScreenshot(path, SceneMarkerScreenshotOptions{ Seconds: seconds, Width: width, })); err != nil { diff --git a/pkg/scene/generate/preview.go b/pkg/scene/generate/preview.go index a0fea4994..46d19a87c 100644 --- a/pkg/scene/generate/preview.go +++ b/pkg/scene/generate/preview.go @@ -21,6 +21,8 @@ const ( scenePreviewImageFPS = 12 + scenePreviewCRF = 21 + minSegmentDuration = 0.75 ) @@ -68,8 +70,8 @@ func (g PreviewOptions) getStepSizeAndOffset(videoDuration float64) (stepSize fl return } -func (g Generator) PreviewVideo(ctx context.Context, input string, videoDuration float64, hash string, options PreviewOptions, fallback bool, useVsync2 bool) error { - lockCtx := g.LockManager.ReadLock(ctx, input) +func (g Generator) PreviewVideo(ctx context.Context, path string, width, height int, videoStreamDuration float64, hash string, options PreviewOptions, fallback bool, useVsync2 bool) error { + lockCtx := g.LockManager.ReadLock(ctx, path) defer lockCtx.Cancel() output := g.ScenePaths.GetVideoPreviewPath(hash) @@ -79,9 +81,9 @@ func (g Generator) PreviewVideo(ctx context.Context, input string, videoDuration } } - logger.Infof("[generator] generating video preview for %s", input) + logger.Infof("[generator] generating video preview for %s", path) - if err := g.generateFile(lockCtx, g.ScenePaths, mp4Pattern, output, g.previewVideo(input, videoDuration, options, fallback, useVsync2)); err != nil { + if err := g.generateFile(lockCtx, g.ScenePaths, mp4Pattern, output, g.previewVideo(path, width, height, videoStreamDuration, options, fallback, useVsync2)); err != nil { return err } @@ -90,10 +92,10 @@ func (g Generator) PreviewVideo(ctx context.Context, input string, videoDuration return nil } -func (g *Generator) previewVideo(input string, videoDuration float64, options PreviewOptions, fallback bool, useVsync2 bool) generateFn { +func (g *Generator) previewVideo(path string, width, height int, videoStreamDuration float64, options PreviewOptions, fallback bool, useVsync2 bool) generateFn { // #2496 - generate a single preview video for videos shorter than segments * segment duration - if videoDuration < options.SegmentDuration*float64(options.Segments) { - return g.previewVideoSingle(input, videoDuration, options, fallback, useVsync2) + if videoStreamDuration < options.SegmentDuration*float64(options.Segments) { + return g.previewVideoSingle(path, width, height, videoStreamDuration, options, fallback, useVsync2) } return func(lockCtx *fsutil.LockContext, tmpFn string) error { @@ -103,7 +105,10 @@ func (g *Generator) previewVideo(input string, videoDuration float64, options Pr // remove tmpFiles when done defer func() { removeFiles(tmpFiles) }() - stepSize, offset := options.getStepSizeAndOffset(videoDuration) + stepSize, offset := options.getStepSizeAndOffset(videoStreamDuration) + + targetHeight := ffmpeg.ScaledHeight(width, height, scenePreviewWidth) + codec, fullhw := g.DetermineCodecAndHW(lockCtx, path, width, height, targetHeight) segmentDuration := options.SegmentDuration // TODO - move this out into calling function @@ -131,7 +136,7 @@ func (g *Generator) previewVideo(input string, videoDuration float64, options Pr Preset: options.Preset, } - if err := g.previewVideoChunk(lockCtx, input, chunkOptions, fallback, useVsync2); err != nil { + if err := g.previewVideoChunk(lockCtx, path, width, height, chunkOptions, fallback, useVsync2, codec, fullhw); err != nil { return err } } @@ -150,17 +155,20 @@ func (g *Generator) previewVideo(input string, videoDuration float64, options Pr } } -func (g *Generator) previewVideoSingle(input string, videoDuration float64, options PreviewOptions, fallback bool, useVsync2 bool) generateFn { +func (g *Generator) previewVideoSingle(path string, width, height int, videoStreamDuration float64, options PreviewOptions, fallback bool, useVsync2 bool) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { + targetHeight := ffmpeg.ScaledHeight(width, height, scenePreviewWidth) + codec, fullhw := g.DetermineCodecAndHW(lockCtx, path, width, height, targetHeight) + chunkOptions := previewChunkOptions{ StartTime: 0, - Duration: videoDuration, + Duration: videoStreamDuration, OutputPath: tmpFn, Audio: options.Audio, Preset: options.Preset, } - return g.previewVideoChunk(lockCtx, input, chunkOptions, fallback, useVsync2) + return g.previewVideoChunk(lockCtx, path, width, height, chunkOptions, fallback, useVsync2, codec, fullhw) } } @@ -172,22 +180,16 @@ type previewChunkOptions struct { Preset string } -func (g Generator) previewVideoChunk(lockCtx *fsutil.LockContext, fn string, options previewChunkOptions, fallback bool, useVsync2 bool) error { - var videoFilter ffmpeg.VideoFilter - videoFilter = videoFilter.ScaleWidth(scenePreviewWidth) +func (g Generator) previewVideoChunk(lockCtx *fsutil.LockContext, path string, width, height int, options previewChunkOptions, fallback bool, useVsync2 bool, codec ffmpeg.VideoCodec, fullhw bool) error { + targetHeight := ffmpeg.ScaledHeight(width, height, scenePreviewWidth) - var videoArgs ffmpeg.Args + videoFilter := g.Encoder.HWMaxResFilter(codec, width, height, targetHeight, fullhw) + + videoArgs := codec.ExtraArgsHQ(options.Preset, scenePreviewCRF) videoArgs = videoArgs.VideoFilter(videoFilter) + videoArgs = append(videoArgs, "-strict", "-2") - videoArgs = append(videoArgs, - "-pix_fmt", "yuv420p", - "-profile:v", "high", - "-level", "4.2", - "-preset", options.Preset, - "-crf", "21", - "-threads", "4", - "-strict", "-2", - ) + extraInputArgs := g.Encoder.HWDeviceInit(g.FFMpegConfig.GetTranscodeInputArgs(), codec, fullhw) if useVsync2 { videoArgs = append(videoArgs, "-vsync", "2") @@ -201,10 +203,10 @@ func (g Generator) previewVideoChunk(lockCtx *fsutil.LockContext, fn string, opt XError: !fallback, SlowSeek: fallback, - VideoCodec: ffmpeg.VideoCodecLibX264, + VideoCodec: codec, VideoArgs: videoArgs, - ExtraInputArgs: g.FFMpegConfig.GetTranscodeInputArgs(), + ExtraInputArgs: extraInputArgs, ExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(), } @@ -216,7 +218,7 @@ func (g Generator) previewVideoChunk(lockCtx *fsutil.LockContext, fn string, opt trimOptions.AudioArgs = audioArgs } - args := transcoder.Transcode(fn, trimOptions) + args := transcoder.Transcode(path, trimOptions) return g.generate(lockCtx, args) } diff --git a/pkg/scene/generate/transcode.go b/pkg/scene/generate/transcode.go index f08d2c05b..b8f2737b7 100644 --- a/pkg/scene/generate/transcode.go +++ b/pkg/scene/generate/transcode.go @@ -9,26 +9,29 @@ import ( "github.com/stashapp/stash/pkg/logger" ) +const transcodeCRF = 23 + type TranscodeOptions struct { - Width int - Height int + // MaxSize is the maximum resolution of the smaller dimension. + // 0 means no scaling. + MaxSize int } -func (g Generator) Transcode(ctx context.Context, input string, hash string, options TranscodeOptions) error { - lockCtx := g.LockManager.ReadLock(ctx, input) +func (g Generator) Transcode(ctx context.Context, path string, width, height int, hash string, options TranscodeOptions) error { + lockCtx := g.LockManager.ReadLock(ctx, path) defer lockCtx.Cancel() - return g.makeTranscode(lockCtx, hash, g.transcode(input, options)) + return g.makeTranscode(lockCtx, hash, g.transcode(path, width, height, options)) } // TranscodeVideo transcodes the video, and removes the audio. // In some videos where the audio codec is not supported by ffmpeg, // ffmpeg fails if you try to transcode the audio -func (g Generator) TranscodeVideo(ctx context.Context, input string, hash string, options TranscodeOptions) error { - lockCtx := g.LockManager.ReadLock(ctx, input) +func (g Generator) TranscodeVideo(ctx context.Context, path string, width, height int, hash string, options TranscodeOptions) error { + lockCtx := g.LockManager.ReadLock(ctx, path) defer lockCtx.Cancel() - return g.makeTranscode(lockCtx, hash, g.transcodeVideo(input, options)) + return g.makeTranscode(lockCtx, hash, g.transcodeVideo(path, width, height, options)) } // TranscodeAudio will copy the video stream as is, and transcode audio. @@ -64,30 +67,36 @@ func (g Generator) makeTranscode(lockCtx *fsutil.LockContext, hash string, gener return nil } -func (g Generator) transcode(input string, options TranscodeOptions) generateFn { +func (g Generator) transcode(path string, width, height int, options TranscodeOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { - var videoArgs ffmpeg.Args - if options.Width != 0 && options.Height != 0 { - var videoFilter ffmpeg.VideoFilter - videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height) - videoArgs = videoArgs.VideoFilter(videoFilter) + targetHeight := height + var videoFilter ffmpeg.VideoFilter + if options.MaxSize != 0 && options.MaxSize < min(width, height) { + if width >= height { + targetHeight = options.MaxSize + } else { + targetHeight = ffmpeg.ScaledHeight(width, height, options.MaxSize) + } } - videoArgs = append(videoArgs, - "-pix_fmt", "yuv420p", - "-profile:v", "high", - "-level", "4.2", - "-preset", "superfast", - "-crf", "23", - ) + codec, fullhw := g.DetermineCodecAndHW(lockCtx, path, width, height, targetHeight) - args := transcoder.Transcode(input, transcoder.TranscodeOptions{ + if options.MaxSize != 0 { + videoFilter = g.Encoder.HWMaxResFilter(codec, width, height, targetHeight, fullhw) + } + + videoArgs := codec.ExtraArgsHQ("superfast", transcodeCRF) + videoArgs = videoArgs.VideoFilter(videoFilter) + + extraInputArgs := g.Encoder.HWDeviceInit(g.FFMpegConfig.GetTranscodeInputArgs(), codec, fullhw) + + args := transcoder.Transcode(path, transcoder.TranscodeOptions{ OutputPath: tmpFn, - VideoCodec: ffmpeg.VideoCodecLibX264, + VideoCodec: codec, VideoArgs: videoArgs, AudioCodec: ffmpeg.AudioCodecAAC, - ExtraInputArgs: g.FFMpegConfig.GetTranscodeInputArgs(), + ExtraInputArgs: extraInputArgs, ExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(), }) @@ -95,33 +104,39 @@ func (g Generator) transcode(input string, options TranscodeOptions) generateFn } } -func (g Generator) transcodeVideo(input string, options TranscodeOptions) generateFn { +func (g Generator) transcodeVideo(path string, width, height int, options TranscodeOptions) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { - var videoArgs ffmpeg.Args - if options.Width != 0 && options.Height != 0 { - var videoFilter ffmpeg.VideoFilter - videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height) - videoArgs = videoArgs.VideoFilter(videoFilter) + targetHeight := height + var videoFilter ffmpeg.VideoFilter + if options.MaxSize != 0 && options.MaxSize < min(width, height) { + if width >= height { + targetHeight = options.MaxSize + } else { + targetHeight = ffmpeg.ScaledHeight(width, height, options.MaxSize) + } } - videoArgs = append(videoArgs, - "-pix_fmt", "yuv420p", - "-profile:v", "high", - "-level", "4.2", - "-preset", "superfast", - "-crf", "23", - ) + codec, fullhw := g.DetermineCodecAndHW(lockCtx, path, width, height, targetHeight) + + if options.MaxSize != 0 { + videoFilter = g.Encoder.HWMaxResFilter(codec, width, height, targetHeight, fullhw) + } + + videoArgs := codec.ExtraArgsHQ("superfast", transcodeCRF) + videoArgs = videoArgs.VideoFilter(videoFilter) var audioArgs ffmpeg.Args audioArgs = audioArgs.SkipAudio() - args := transcoder.Transcode(input, transcoder.TranscodeOptions{ + extraInputArgs := g.Encoder.HWDeviceInit(g.FFMpegConfig.GetTranscodeInputArgs(), codec, fullhw) + + args := transcoder.Transcode(path, transcoder.TranscodeOptions{ OutputPath: tmpFn, - VideoCodec: ffmpeg.VideoCodecLibX264, + VideoCodec: codec, VideoArgs: videoArgs, AudioArgs: audioArgs, - ExtraInputArgs: g.FFMpegConfig.GetTranscodeInputArgs(), + ExtraInputArgs: extraInputArgs, ExtraOutputArgs: g.FFMpegConfig.GetTranscodeOutputArgs(), }) diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index ba8215fe3..64d7b610a 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -26,6 +26,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { previewExcludeEnd previewPreset transcodeHardwareAcceleration + generationHardwareAcceleration maxTranscodeSize maxStreamingTranscodeSize writeImageThumbnails diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index 446ad09a1..14d33c84a 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -336,6 +336,14 @@ export const SettingsConfigurationPanel: React.FC = () => { onChange={(v) => saveGeneral({ transcodeHardwareAcceleration: v })} /> + saveGeneral({ generationHardwareAcceleration: v })} + /> +