From 4ca6fcb5c06a4697adb9e467c399ae16b2f56e37 Mon Sep 17 00:00:00 2001 From: Nodude <75137537+NodudeWasTaken@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:45:03 +0200 Subject: [PATCH] Allow hardware acceleration for generation tasks Adds a separate generationHardwareAcceleration config toggle that opts the preview, marker, transcode, and clip-thumbnail tasks into the existing HW pipeline. Generator methods take path/width/height primitives; the marker task only probes for HW when a video preview is actually requested. On NVENC: 8K/41min/15-marker scene goes from 8m to 5m; 1080p/9min with no markers 15s to 14s. Short 720p clips are slower. Reduce concurrent tasks if VRAM is limited. --- graphql/schema/types/config.graphql | 4 + internal/api/resolver_mutation_configure.go | 1 + internal/api/resolver_query_configuration.go | 119 +++++++++--------- internal/api/routes_image.go | 1 + internal/manager/config/config.go | 9 +- .../manager/task_generate_clip_preview.go | 1 + .../manager/task_generate_image_thumbnail.go | 1 + internal/manager/task_generate_markers.go | 22 +++- internal/manager/task_generate_preview.go | 13 +- internal/manager/task_transcode.go | 11 +- pkg/ffmpeg/filter.go | 6 + pkg/image/thumbnail.go | 37 +++--- pkg/scene/generate/generator.go | 13 ++ pkg/scene/generate/marker_preview.go | 55 ++++---- pkg/scene/generate/preview.go | 58 ++++----- pkg/scene/generate/transcode.go | 95 ++++++++------ ui/v2.5/graphql/data/config.graphql | 1 + .../Settings/SettingsSystemPanel.tsx | 8 ++ ui/v2.5/src/locales/en-GB.json | 8 +- 19 files changed, 273 insertions(+), 190 deletions(-) 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/filter.go b/pkg/ffmpeg/filter.go index 254076dca..954b57288 100644 --- a/pkg/ffmpeg/filter.go +++ b/pkg/ffmpeg/filter.go @@ -36,6 +36,12 @@ func (f VideoFilter) ScaleMaxSize(maxDimensions int) VideoFilter { return f.Append(fmt.Sprintf("scale=%v:%v:force_original_aspect_ratio=decrease", maxDimensions, maxDimensions)) } +// 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 { 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 ceefd617c..d5513fecf 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 })} + /> +