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.
This commit is contained in:
Nodude 2026-04-22 19:45:03 +02:00
parent 900305685c
commit 4ca6fcb5c0
19 changed files with 273 additions and 190 deletions

View file

@ -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"

View file

@ -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())
}

View file

@ -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(),
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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,
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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(),
})

View file

@ -26,6 +26,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
previewExcludeEnd
previewPreset
transcodeHardwareAcceleration
generationHardwareAcceleration
maxTranscodeSize
maxStreamingTranscodeSize
writeImageThumbnails

View file

@ -336,6 +336,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
onChange={(v) => saveGeneral({ transcodeHardwareAcceleration: v })}
/>
<BooleanSetting
id="hardware-encoding-generation"
headingID="config.general.ffmpeg.hardware_acceleration_generate.heading"
subHeadingID="config.general.ffmpeg.hardware_acceleration_generate.desc"
checked={general.generationHardwareAcceleration ?? false}
onChange={(v) => saveGeneral({ generationHardwareAcceleration: v })}
/>
<StringListSetting
advanced
id="transcode-input-args"

View file

@ -377,8 +377,12 @@
"heading": "FFprobe executable path"
},
"hardware_acceleration": {
"desc": "Uses available hardware to encode video for live transcoding.",
"heading": "FFmpeg hardware encoding"
"desc": "Uses available hardware for live transcoding.",
"heading": "FFmpeg transcoding hardware acceleration"
},
"hardware_acceleration_generate": {
"desc": "Uses available hardware for video encoding tasks.",
"heading": "FFmpeg generation hardware acceleration"
},
"live_transcode": {
"input_args": {