This commit is contained in:
NodudeWasTaken 2026-05-05 08:03:25 -05:00 committed by GitHub
commit 307447697e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 513 additions and 362 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

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

View file

@ -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<value>([-\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
}

View file

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

View file

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

View file

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

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": {