Custom sprite generation (#6588)

* configurable minimum/maximum number of sprites
* configurable sprite size
---------
Co-authored-by: cacheflush <github.stoneware268@passmail.com>
This commit is contained in:
WithoutPants 2026-02-20 15:09:59 +11:00 committed by GitHub
parent 843806247d
commit 076032ff8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 343 additions and 38 deletions

View file

@ -184,6 +184,18 @@ input ConfigGeneralInput {
scraperPackageSources: [PackageSourceInput!]
"Source of plugin packages"
pluginPackageSources: [PackageSourceInput!]
"Size of the longest dimension for each sprite in pixels"
spriteScreenshotSize: Int
"True if sprite generation should use the sprite interval and min/max sprites settings instead of the default"
useCustomSpriteInterval: Boolean
"Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true"
spriteInterval: Float
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
minimumSprites: Int
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
maximumSprites: Int
}
type ConfigGeneralResult {
@ -287,6 +299,16 @@ type ConfigGeneralResult {
logAccess: Boolean!
"Maximum log size"
logFileMaxSize: Int!
"True if sprite generation should use the sprite interval and min/max sprites settings instead of the default"
useCustomSpriteInterval: Boolean!
"Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true"
spriteInterval: Float!
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
minimumSprites: Int!
"Maximum number of sprites to be generated - only used if useCustomSpriteInterval is true"
maximumSprites: Int!
"Size of the longest dimension for each sprite in pixels"
spriteScreenshotSize: Int!
"Array of video file extensions"
videoExtensions: [String!]!
"Array of image file extensions"

View file

@ -287,6 +287,11 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if input.PreviewPreset != nil {
c.SetString(config.PreviewPreset, input.PreviewPreset.String())
}
r.setConfigBool(config.UseCustomSpriteInterval, input.UseCustomSpriteInterval)
r.setConfigFloat(config.SpriteInterval, input.SpriteInterval)
r.setConfigInt(config.MinimumSprites, input.MinimumSprites)
r.setConfigInt(config.MaximumSprites, input.MaximumSprites)
r.setConfigInt(config.SpriteScreenshotSize, input.SpriteScreenshotSize)
r.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration)
if input.MaxTranscodeSize != nil {

View file

@ -96,6 +96,11 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
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(),

View file

@ -83,6 +83,21 @@ const (
ParallelTasks = "parallel_tasks"
parallelTasksDefault = 1
UseCustomSpriteInterval = "use_custom_sprite_interval"
UseCustomSpriteIntervalDefault = false
SpriteInterval = "sprite_interval"
SpriteIntervalDefault = 30
MinimumSprites = "minimum_sprites"
MinimumSpritesDefault = 10
MaximumSprites = "maximum_sprites"
MaximumSpritesDefault = 500
SpriteScreenshotSize = "sprite_screenshot_width"
spriteScreenshotSizeDefault = 160
PreviewPreset = "preview_preset"
TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration"
@ -975,6 +990,50 @@ func (i *Config) GetParallelTasksWithAutoDetection() int {
return parallelTasks
}
// GetUseCustomSpriteInterval returns true if the sprite minimum, maximum, and interval settings
// should be used instead of the default
func (i *Config) GetUseCustomSpriteInterval() bool {
value := i.getBool(UseCustomSpriteInterval)
return value
}
// GetSpriteInterval returns the time (in seconds) to be between each scrubber sprite
// A value of 0 indicates that the sprite interval should be automatically determined
// based on the minimum sprite setting.
func (i *Config) GetSpriteInterval() float64 {
value := i.getFloat64(SpriteInterval)
return value
}
// GetMinimumSprites returns the minimum number of sprites that have to be generated
// A value of 0 will be overridden with the default of 10.
func (i *Config) GetMinimumSprites() int {
value := i.getInt(MinimumSprites)
if value <= 0 {
return MinimumSpritesDefault
}
return value
}
// GetMaximumSprites returns the maximum number of sprites that can be generated
// A value of 0 indicates no maximum.
func (i *Config) GetMaximumSprites() int {
value := i.getInt(MaximumSprites)
return value
}
// GetSpriteScreenshotSize returns the required size of the screenshots to be taken
// during sprite generation in pixels. This will be the width for landscape scenes
// and the height for portrait scenes, with the other dimension being scaled to maintain
// the aspect ratio. If the value is less than or equal to 0, the default will be used.
func (i *Config) GetSpriteScreenshotSize() int {
value := i.getInt(SpriteScreenshotSize)
if value <= 0 {
return spriteScreenshotSizeDefault
}
return value
}
func (i *Config) GetPreviewAudio() bool {
return i.getBool(PreviewAudio)
}
@ -1861,6 +1920,12 @@ func (i *Config) setDefaultValues() {
i.setDefault(PreviewAudio, previewAudioDefault)
i.setDefault(SoundOnPreview, false)
i.setDefault(UseCustomSpriteInterval, UseCustomSpriteIntervalDefault)
i.setDefault(SpriteInterval, SpriteIntervalDefault)
i.setDefault(MinimumSprites, MinimumSpritesDefault)
i.setDefault(MaximumSprites, MaximumSpritesDefault)
i.setDefault(SpriteScreenshotSize, spriteScreenshotSizeDefault)
i.setDefault(ThemeColor, DefaultThemeColor)
i.setDefault(WriteImageThumbnails, writeImageThumbnailsDefault)

View file

@ -21,8 +21,7 @@ type SpriteGenerator struct {
VideoChecksum string
ImageOutputPath string
VTTOutputPath string
Rows int
Columns int
Config SpriteGeneratorConfig
SlowSeek bool // use alternate seek function, very slow!
Overwrite bool
@ -30,13 +29,81 @@ type SpriteGenerator struct {
g *generate.Generator
}
func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, rows int, cols int) (*SpriteGenerator, error) {
// SpriteGeneratorConfig holds configuration for the SpriteGenerator
type SpriteGeneratorConfig struct {
// MinimumSprites is the minimum number of sprites to generate, even if the video duration is short
// SpriteInterval will be adjusted accordingly to ensure at least this many sprites are generated.
// A value of 0 means no minimum, and the generator will use the provided SpriteInterval or
// calculate it based on the video duration and MaximumSprites
MinimumSprites int
// MaximumSprites is the maximum number of sprites to generate, even if the video duration is long
// SpriteInterval will be adjusted accordingly to ensure no more than this many sprites are generated
// A value of 0 means no maximum, and the generator will use the provided SpriteInterval or
// calculate it based on the video duration and MinimumSprites
MaximumSprites int
// SpriteInterval is the default interval in seconds between each sprite.
// If MinimumSprites or MaximumSprites are set, this value will be adjusted accordingly
// to ensure the desired number of sprites are generated
// A value of 0 means the generator will calculate the interval based on the video duration and
// the provided MinimumSprites and MaximumSprites
SpriteInterval float64
// SpriteSize is the size in pixels of the longest dimension of each sprite image.
// The other dimension will be automatically calculated to maintain the aspect ratio of the video
SpriteSize int
}
const (
// DefaultSpriteAmount is the default number of sprites to generate if no configuration is provided
// This corresponds to the legacy behavior of the generator, which generates 81 sprites at equal
// intervals across the video duration
DefaultSpriteAmount = 81
// DefaultSpriteSize is the default size in pixels of the longest dimension of each sprite image
// if no configuration is provided. This corresponds to the legacy behavior of the generator.
DefaultSpriteSize = 160
)
var DefaultSpriteGeneratorConfig = SpriteGeneratorConfig{
MinimumSprites: DefaultSpriteAmount,
MaximumSprites: DefaultSpriteAmount,
SpriteInterval: 0,
SpriteSize: DefaultSpriteSize,
}
// NewSpriteGenerator creates a new SpriteGenerator for the given video file and configuration
// It calculates the appropriate sprite interval and count based on the video duration and the provided configuration
func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, config SpriteGeneratorConfig) (*SpriteGenerator, error) {
exists, err := fsutil.FileExists(videoFile.Path)
if !exists {
return nil, err
}
if videoFile.VideoStreamDuration <= 0 {
s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount)
return nil, errors.New(s)
}
config.SpriteInterval = calculateSpriteInterval(videoFile, config)
chunkCount := int(math.Ceil(videoFile.VideoStreamDuration / config.SpriteInterval))
// adjust the chunk count to the next highest perfect square, to ensure the sprite image
// is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns)
gridSize := generate.GetSpriteGridSize(chunkCount)
newChunkCount := gridSize * gridSize
if newChunkCount != chunkCount {
logger.Debugf("[generator] adjusting chunk count from %d to %d to fit a %dx%d grid", chunkCount, newChunkCount, gridSize, gridSize)
chunkCount = newChunkCount
}
if config.SpriteSize <= 0 {
config.SpriteSize = DefaultSpriteSize
}
slowSeek := false
chunkCount := rows * cols
// For files with small duration / low frame count try to seek using frame number intead of seconds
if videoFile.VideoStreamDuration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5
@ -71,9 +138,8 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
VideoChecksum: videoChecksum,
ImageOutputPath: imageOutputPath,
VTTOutputPath: vttOutputPath,
Rows: rows,
Config: config,
SlowSeek: slowSeek,
Columns: cols,
g: &generate.Generator{
Encoder: instance.FFMpeg,
FFMpegConfig: instance.Config,
@ -83,6 +149,40 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
}, nil
}
func calculateSpriteInterval(videoFile ffmpeg.VideoFile, config SpriteGeneratorConfig) float64 {
// If a custom sprite interval is provided, start with that
spriteInterval := config.SpriteInterval
// If no custom interval is provided, calculate the interval based on the
// video duration and minimum sprite count
if spriteInterval <= 0 {
minSprites := config.MinimumSprites
if minSprites <= 0 {
panic("invalid configuration: MinimumSprites must be greater than 0 if SpriteInterval is not set")
}
logger.Debugf("[generator] calculating sprite interval for video duration %.3fs with minimum sprites %d", videoFile.VideoStreamDuration, minSprites)
return videoFile.VideoStreamDuration / float64(minSprites)
}
// Calculate the number of sprites that would be generated with the provided interval
spriteCount := int(math.Ceil(videoFile.VideoStreamDuration / spriteInterval))
// If the calculated sprite count is greater than the maximum, adjust the interval to meet the maximum
if config.MaximumSprites > 0 && spriteCount > int(config.MaximumSprites) {
spriteInterval = videoFile.VideoStreamDuration / float64(config.MaximumSprites)
logger.Debugf("[generator] provided sprite interval %.1fs results in %d sprites, which exceeds the maximum of %d, adjusting interval to %.1fs", config.SpriteInterval, spriteCount, config.MaximumSprites, spriteInterval)
}
// If the calculated sprite count is less than the minimum, adjust the interval to meet the minimum
if config.MinimumSprites > 0 && spriteCount < int(config.MinimumSprites) {
spriteInterval = videoFile.VideoStreamDuration / float64(config.MinimumSprites)
logger.Debugf("[generator] provided sprite interval %.1fs results in %d sprites, which is less than the minimum of %d, adjusting interval to %.1fs", config.SpriteInterval, spriteCount, config.MinimumSprites, spriteInterval)
}
return spriteInterval
}
func (g *SpriteGenerator) Generate() error {
if err := g.generateSpriteImage(); err != nil {
return err
@ -100,6 +200,8 @@ func (g *SpriteGenerator) generateSpriteImage() error {
var images []image.Image
isPortrait := g.Info.VideoFile.Height > g.Info.VideoFile.Width
if !g.SlowSeek {
logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path)
// generate `ChunkCount` thumbnails
@ -107,8 +209,7 @@ func (g *SpriteGenerator) generateSpriteImage() error {
for i := 0; i < g.Info.ChunkCount; i++ {
time := float64(i) * stepSize
img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time)
img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time, g.Config.SpriteSize, isPortrait)
if err != nil {
return err
}
@ -126,7 +227,7 @@ func (g *SpriteGenerator) generateSpriteImage() error {
return errors.New("invalid frame number conversion")
}
img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame))
img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame), g.Config.SpriteSize)
if err != nil {
return err
}
@ -158,7 +259,7 @@ func (g *SpriteGenerator) generateSpriteVTT() error {
stepSize /= g.Info.FrameRate
}
return g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize)
return g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize, g.Info.ChunkCount)
}
func (g *SpriteGenerator) imageExists() bool {

View file

@ -34,7 +34,17 @@ func (t *GenerateSpriteTask) Start(ctx context.Context) {
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
imagePath := instance.Paths.Scene.GetSpriteImageFilePath(sceneHash)
vttPath := instance.Paths.Scene.GetSpriteVttFilePath(sceneHash)
generator, err := NewSpriteGenerator(*videoFile, sceneHash, imagePath, vttPath, 9, 9)
cfg := DefaultSpriteGeneratorConfig
cfg.SpriteSize = instance.Config.GetSpriteScreenshotSize()
if instance.Config.GetUseCustomSpriteInterval() {
cfg.MinimumSprites = instance.Config.GetMinimumSprites()
cfg.MaximumSprites = instance.Config.GetMaximumSprites()
cfg.SpriteInterval = instance.Config.GetSpriteInterval()
}
generator, err := NewSpriteGenerator(*videoFile, sceneHash, imagePath, vttPath, cfg)
if err != nil {
logger.Errorf("error creating sprite generator: %s", err.Error())

View file

@ -9,7 +9,11 @@ type ScreenshotOptions struct {
// Quality is the quality scale. See https://ffmpeg.org/ffmpeg.html#Main-options
Quality int
// Width is the width to scale the screenshot to. If 0, no scaling will be applied.
Width int
// Height is the height to scale the screenshot to. If 0, no scaling will be applied.
// Not used if Width is set.
Height int
// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.
Verbosity ffmpeg.LogLevel
@ -70,6 +74,9 @@ func ScreenshotTime(input string, t float64, options ScreenshotOptions) ffmpeg.A
if options.Width > 0 {
vf = vf.ScaleWidth(options.Width)
args = args.VideoFilter(vf)
} else if options.Height > 0 {
vf = vf.ScaleHeight(options.Height)
args = args.VideoFilter(vf)
}
args = args.AppendArgs(options.OutputType)

View file

@ -18,22 +18,19 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
const (
spriteScreenshotWidth = 160
spriteRows = 9
spriteCols = 9
spriteChunks = spriteRows * spriteCols
)
func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds float64) (image.Image, error) {
func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds float64, size int, isPortrait bool) (image.Image, error) {
lockCtx := g.LockManager.ReadLock(ctx, input)
defer lockCtx.Cancel()
ssOptions := transcoder.ScreenshotOptions{
OutputPath: "-",
OutputType: transcoder.ScreenshotOutputTypeBMP,
Width: spriteScreenshotWidth,
}
if !isPortrait {
ssOptions.Width = size
} else {
ssOptions.Height = size
}
args := transcoder.ScreenshotTime(input, seconds, ssOptions)
@ -41,14 +38,14 @@ func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds f
return g.generateImage(lockCtx, args)
}
func (g Generator) SpriteScreenshotSlow(ctx context.Context, input string, frame int) (image.Image, error) {
func (g Generator) SpriteScreenshotSlow(ctx context.Context, input string, frame int, width int) (image.Image, error) {
lockCtx := g.LockManager.ReadLock(ctx, input)
defer lockCtx.Cancel()
ssOptions := transcoder.ScreenshotOptions{
OutputPath: "-",
OutputType: transcoder.ScreenshotOutputTypeBMP,
Width: spriteScreenshotWidth,
Width: width,
}
args := transcoder.ScreenshotFrame(input, frame, ssOptions)
@ -74,12 +71,13 @@ func (g Generator) CombineSpriteImages(images []image.Image) image.Image {
// Combine all of the thumbnails into a sprite image
width := images[0].Bounds().Size().X
height := images[0].Bounds().Size().Y
canvasWidth := width * spriteCols
canvasHeight := height * spriteRows
gridSize := GetSpriteGridSize(len(images))
canvasWidth := width * gridSize
canvasHeight := height * gridSize
montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{})
for index := 0; index < len(images); index++ {
x := width * (index % spriteCols)
y := height * int(math.Floor(float64(index)/float64(spriteRows)))
x := width * (index % gridSize)
y := height * int(math.Floor(float64(index)/float64(gridSize)))
img := images[index]
montage = imaging.Paste(montage, img, image.Pt(x, y))
}
@ -87,14 +85,19 @@ func (g Generator) CombineSpriteImages(images []image.Image) image.Image {
return montage
}
func (g Generator) SpriteVTT(ctx context.Context, output string, spritePath string, stepSize float64) error {
lockCtx := g.LockManager.ReadLock(ctx, spritePath)
defer lockCtx.Cancel()
return g.generateFile(lockCtx, g.ScenePaths, vttPattern, output, g.spriteVTT(spritePath, stepSize))
// GetSpriteGridSize return the required size of a grid, where the number of images in width
// equals the number of images in height, to hold 'imageCount' images
func GetSpriteGridSize(imageCount int) int {
return int(math.Ceil(math.Sqrt(float64(imageCount))))
}
func (g Generator) spriteVTT(spritePath string, stepSize float64) generateFn {
func (g Generator) SpriteVTT(ctx context.Context, output string, spritePath string, stepSize float64, spriteChunks int) error {
lockCtx := g.LockManager.ReadLock(ctx, spritePath)
defer lockCtx.Cancel()
return g.generateFile(lockCtx, g.ScenePaths, vttPattern, output, g.spriteVTT(spritePath, stepSize, spriteChunks))
}
func (g Generator) spriteVTT(spritePath string, stepSize float64, spriteChunks int) generateFn {
return func(lockCtx *fsutil.LockContext, tmpFn string) error {
spriteImage, err := os.Open(spritePath)
if err != nil {
@ -106,16 +109,17 @@ func (g Generator) spriteVTT(spritePath string, stepSize float64) generateFn {
if err != nil {
return err
}
width := image.Width / spriteCols
height := image.Height / spriteRows
gridSize := GetSpriteGridSize(spriteChunks)
width := image.Width / gridSize
height := image.Height / gridSize
vttLines := []string{"WEBVTT", ""}
for index := 0; index < spriteChunks; index++ {
x := width * (index % spriteCols)
y := height * int(math.Floor(float64(index)/float64(spriteRows)))
x := width * (index % gridSize)
y := height * int(math.Floor(float64(index)/float64(gridSize)))
startTime := utils.GetVTTTime(float64(index) * stepSize)
endTime := utils.GetVTTTime(float64(index+1) * stepSize)
vttLines = append(vttLines, startTime+" --> "+endTime)
vttLines = append(vttLines, fmt.Sprintf("%s#xywh=%d,%d,%d,%d", spriteImageName, x, y, width, height))
vttLines = append(vttLines, "")

View file

@ -39,6 +39,11 @@ fragment ConfigGeneralData on ConfigGeneralResult {
logLevel
logAccess
logFileMaxSize
useCustomSpriteInterval
spriteInterval
minimumSprites
maximumSprites
spriteScreenshotSize
createGalleriesFromFolders
galleryCoverRegex
videoExtensions

View file

@ -67,6 +67,8 @@ export const PreviewScrubber: React.FC<IScenePreviewProps> = ({
const clientRect = imageParent.getBoundingClientRect();
const scale = scaleToFit(sprite, clientRect);
const spriteSheet = new Image();
spriteSheet.src = sprite.url;
setStyle({
backgroundPosition: `${-sprite.x}px ${-sprite.y}px`,

View file

@ -427,6 +427,44 @@ export const SettingsConfigurationPanel: React.FC = () => {
/>
</SettingSection>
<SettingSection headingID="config.general.sprite_generation_head">
<NumberSetting
id="sprite-screenshot-size"
headingID="config.general.sprite_screenshot_size_head"
subHeadingID="config.general.sprite_screenshot_size_desc"
value={general.spriteScreenshotSize ?? 160}
onChange={(v) => saveGeneral({ spriteScreenshotSize: v })}
/>
<BooleanSetting
id="use-custom-sprite-interval"
headingID="config.general.use_custom_sprite_interval_head"
subHeadingID="config.general.use_custom_sprite_interval_desc"
checked={general.useCustomSpriteInterval ?? false}
onChange={(v) => saveGeneral({ useCustomSpriteInterval: v })}
/>
<NumberSetting
id="sprite-interval"
headingID="config.general.sprite_interval_head"
subHeadingID="config.general.sprite_interval_desc"
value={general.spriteInterval ?? 0}
onChange={(v) => saveGeneral({ spriteInterval: v })}
/>
<NumberSetting
id="minimum-sprites"
headingID="config.general.sprite_minimum_head"
subHeadingID="config.general.sprite_minimum_desc"
value={general.minimumSprites ?? 10}
onChange={(v) => saveGeneral({ minimumSprites: v })}
/>
<NumberSetting
id="maximum-sprites"
headingID="config.general.sprite_maximum_head"
subHeadingID="config.general.sprite_maximum_desc"
value={general.maximumSprites ?? 10}
onChange={(v) => saveGeneral({ maximumSprites: v })}
/>
</SettingSection>
<SettingSection headingID="config.general.heatmap_generation">
<BooleanSetting
id="heatmap-draw-range"

View file

@ -89,6 +89,36 @@ This setting can be used to increase/decrease overall CPU utilisation in two sce
> **⚠️ Note:** If this is set too high it will decrease overall performance and causes failures (out of memory).
## Sprite generation
### Sprite size
Fixed size of a generated sprite, being the longest dimension in pixels.
Setting this to `0` will fallback to the default of `160`.
Althought it is possible to set this value to anything bigger than `0` it is recommended to set it to `160` at least.
### Use custom sprite generation
If this setting is disabled, the settings below will be ignored and the default sprite generation settings are used.
### Sprite interval
This represents the time in seconds between each sprite to be generated. This value will be adjusted if necessary to fit within the bounds of the `Minimum Sprites` and `Maximum Sprites` settings.
Setting this to `0` means that the sprite interval will be calculated based on the value of the `Minimum Sprites` field.
### Minimum sprites
The minimal number of distinct sprites that will be generated for a scene. `Sprite interval` will be adjusted if necessary.
Setting this to `0` will fallback to the default of `10`
### Maximum sprites
The maximum number of distinct sprites that will be generated for a scene. `Sprite interval` will be adjusted if necessary.
Setting this to `0` indicates there is no maximum.
> **⚠️ Note:** The number of generated sprites is adjusted upwards to the next perfect square to ensure the sprite image is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns). This means that if you set a minimum of 10 sprites, 16 will actually be generated, and if you set a maximum of 15 sprites, 16 will actually be generated.
## Hardware accelerated live transcoding
Hardware accelerated live transcoding can be enabled by setting the `FFmpeg hardware encoding` setting. Stash outputs the supported hardware encoders to the log file on startup at the Info log level. If a given hardware encoder is not supported, it's error message is logged to the Debug log level for debugging purposes.

View file

@ -440,7 +440,18 @@
"heading": "Scrapers path"
},
"scraping": "Scraping",
"sprite_generation_head": "Sprite generation",
"sprite_interval_desc": "Time between each generated sprite in seconds.",
"sprite_interval_head": "Sprite interval",
"sprite_maximum_desc": "Maximum number of sprites to be generated for a scene. Set to 0 to disable the limit.",
"sprite_maximum_head": "Maximum sprites",
"sprite_minimum_desc": "Minimum number of sprites to be generated for a scene",
"sprite_minimum_head": "Minimum sprites",
"sprite_screenshot_size_desc": "Desired size of each sprite in pixels.",
"sprite_screenshot_size_head": "Sprite size",
"sqlite_location": "File location for the SQLite database (requires restart). WARNING: storing the database on a different system to where the Stash server is run from (i.e. over the network) is unsupported!",
"use_custom_sprite_interval_head": "Use custom sprite interval",
"use_custom_sprite_interval_desc": "Enable the custom sprite interval according to the settings below.",
"video_ext_desc": "Comma-delimited list of file extensions that will be identified as videos.",
"video_ext_head": "Video extensions",
"video_head": "Video"