mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
Add option to generate image thumbnails during generate (#4602)
* Add option to generate image thumbnails * Limit number of concurrent image thumbnail generation ops
This commit is contained in:
parent
c4a91d15a6
commit
a8c909e0c9
14 changed files with 332 additions and 222 deletions
|
|
@ -12,6 +12,7 @@ input GenerateMetadataInput {
|
||||||
forceTranscodes: Boolean
|
forceTranscodes: Boolean
|
||||||
phashes: Boolean
|
phashes: Boolean
|
||||||
interactiveHeatmapsSpeeds: Boolean
|
interactiveHeatmapsSpeeds: Boolean
|
||||||
|
imageThumbnails: Boolean
|
||||||
clipPreviews: Boolean
|
clipPreviews: Boolean
|
||||||
|
|
||||||
"scene ids to generate for"
|
"scene ids to generate for"
|
||||||
|
|
@ -48,6 +49,7 @@ type GenerateMetadataOptions {
|
||||||
transcodes: Boolean
|
transcodes: Boolean
|
||||||
phashes: Boolean
|
phashes: Boolean
|
||||||
interactiveHeatmapsSpeeds: Boolean
|
interactiveHeatmapsSpeeds: Boolean
|
||||||
|
imageThumbnails: Boolean
|
||||||
clipPreviews: Boolean
|
clipPreviews: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,9 @@ func (rs imageRoutes) Routes() chi.Router {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mgr := manager.GetInstance()
|
||||||
img := r.Context().Value(imageKey).(*models.Image)
|
img := r.Context().Value(imageKey).(*models.Image)
|
||||||
filepath := manager.GetInstance().Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
|
filepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
|
||||||
|
|
||||||
// if the thumbnail doesn't exist, encode on the fly
|
// if the thumbnail doesn't exist, encode on the fly
|
||||||
exists, _ := fsutil.FileExists(filepath)
|
exists, _ := fsutil.FileExists(filepath)
|
||||||
|
|
@ -62,6 +63,11 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// use the image thumbnail generate wait group to limit the number of concurrent thumbnail generation tasks
|
||||||
|
wg := &mgr.ImageThumbnailGenerateWaitGroup
|
||||||
|
wg.Add()
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
clipPreviewOptions := image.ClipPreviewOptions{
|
clipPreviewOptions := image.ClipPreviewOptions{
|
||||||
InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(),
|
InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(),
|
||||||
OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(),
|
OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/remeh/sizedwaitgroup"
|
||||||
"github.com/stashapp/stash/internal/desktop"
|
"github.com/stashapp/stash/internal/desktop"
|
||||||
"github.com/stashapp/stash/internal/dlna"
|
"github.com/stashapp/stash/internal/dlna"
|
||||||
"github.com/stashapp/stash/internal/log"
|
"github.com/stashapp/stash/internal/log"
|
||||||
|
|
@ -80,6 +81,8 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
|
||||||
|
|
||||||
Paths: mgrPaths,
|
Paths: mgrPaths,
|
||||||
|
|
||||||
|
ImageThumbnailGenerateWaitGroup: sizedwaitgroup.New(1),
|
||||||
|
|
||||||
JobManager: initJobManager(cfg),
|
JobManager: initJobManager(cfg),
|
||||||
ReadLockManager: fsutil.NewReadLockManager(),
|
ReadLockManager: fsutil.NewReadLockManager(),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/remeh/sizedwaitgroup"
|
||||||
"github.com/stashapp/stash/internal/dlna"
|
"github.com/stashapp/stash/internal/dlna"
|
||||||
"github.com/stashapp/stash/internal/log"
|
"github.com/stashapp/stash/internal/log"
|
||||||
"github.com/stashapp/stash/internal/manager/config"
|
"github.com/stashapp/stash/internal/manager/config"
|
||||||
|
|
@ -33,6 +34,10 @@ type Manager struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
|
|
||||||
|
// ImageThumbnailGenerateWaitGroup is the global wait group image thumbnail generation
|
||||||
|
// It uses the parallel tasks setting from the configuration.
|
||||||
|
ImageThumbnailGenerateWaitGroup sizedwaitgroup.SizedWaitGroup
|
||||||
|
|
||||||
Paths *paths.Paths
|
Paths *paths.Paths
|
||||||
|
|
||||||
FFMpeg *ffmpeg.FFMpeg
|
FFMpeg *ffmpeg.FFMpeg
|
||||||
|
|
@ -107,6 +112,8 @@ func (s *Manager) RefreshConfig() {
|
||||||
if err := fsutil.EnsureDir(s.Paths.Generated.InteractiveHeatmap); err != nil {
|
if err := fsutil.EnsureDir(s.Paths.Generated.InteractiveHeatmap); err != nil {
|
||||||
logger.Warnf("could not create interactive heatmaps directory: %v", err)
|
logger.Warnf("could not create interactive heatmaps directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.ImageThumbnailGenerateWaitGroup.Size = cfg.GetParallelTasksWithAutoDetection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type GenerateMetadataInput struct {
|
||||||
Phashes bool `json:"phashes"`
|
Phashes bool `json:"phashes"`
|
||||||
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
|
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
|
||||||
ClipPreviews bool `json:"clipPreviews"`
|
ClipPreviews bool `json:"clipPreviews"`
|
||||||
|
ImageThumbnails bool `json:"imageThumbnails"`
|
||||||
// scene ids to generate for
|
// scene ids to generate for
|
||||||
SceneIDs []string `json:"sceneIDs"`
|
SceneIDs []string `json:"sceneIDs"`
|
||||||
// marker ids to generate for
|
// marker ids to generate for
|
||||||
|
|
@ -60,6 +61,8 @@ type GenerateJob struct {
|
||||||
|
|
||||||
overwrite bool
|
overwrite bool
|
||||||
fileNamingAlgo models.HashAlgorithm
|
fileNamingAlgo models.HashAlgorithm
|
||||||
|
|
||||||
|
totals totalsGenerate
|
||||||
}
|
}
|
||||||
|
|
||||||
type totalsGenerate struct {
|
type totalsGenerate struct {
|
||||||
|
|
@ -72,6 +75,7 @@ type totalsGenerate struct {
|
||||||
phashes int64
|
phashes int64
|
||||||
interactiveHeatmapSpeeds int64
|
interactiveHeatmapSpeeds int64
|
||||||
clipPreviews int64
|
clipPreviews int64
|
||||||
|
imageThumbnails int64
|
||||||
|
|
||||||
tasks int
|
tasks int
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +97,6 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||||
go func() {
|
go func() {
|
||||||
defer close(queue)
|
defer close(queue)
|
||||||
|
|
||||||
var totals totalsGenerate
|
|
||||||
sceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs)
|
sceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err.Error())
|
logger.Error(err.Error())
|
||||||
|
|
@ -116,7 +119,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||||
qb := r.Scene
|
qb := r.Scene
|
||||||
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 {
|
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 {
|
||||||
totals = j.queueTasks(ctx, g, queue)
|
j.queueTasks(ctx, g, queue)
|
||||||
} else {
|
} else {
|
||||||
if len(j.input.SceneIDs) > 0 {
|
if len(j.input.SceneIDs) > 0 {
|
||||||
scenes, err = qb.FindMany(ctx, sceneIDs)
|
scenes, err = qb.FindMany(ctx, sceneIDs)
|
||||||
|
|
@ -125,7 +128,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
j.queueSceneJobs(ctx, g, s, queue, &totals)
|
j.queueSceneJobs(ctx, g, s, queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +138,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, m := range markers {
|
for _, m := range markers {
|
||||||
j.queueMarkerJob(g, m, queue, &totals)
|
j.queueMarkerJob(g, m, queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -146,6 +149,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totals := j.totals
|
||||||
logMsg := "Generating"
|
logMsg := "Generating"
|
||||||
if j.input.Covers {
|
if j.input.Covers {
|
||||||
logMsg += fmt.Sprintf(" %d covers", totals.covers)
|
logMsg += fmt.Sprintf(" %d covers", totals.covers)
|
||||||
|
|
@ -174,6 +178,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||||
if j.input.ClipPreviews {
|
if j.input.ClipPreviews {
|
||||||
logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews)
|
logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews)
|
||||||
}
|
}
|
||||||
|
if j.input.ImageThumbnails {
|
||||||
|
logMsg += fmt.Sprintf(" %d Image Thumbnails", totals.imageThumbnails)
|
||||||
|
}
|
||||||
if logMsg == "Generating" {
|
if logMsg == "Generating" {
|
||||||
logMsg = "Nothing selected to generate"
|
logMsg = "Nothing selected to generate"
|
||||||
}
|
}
|
||||||
|
|
@ -223,9 +230,14 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||||
logger.Info(fmt.Sprintf("Generate finished (%s)", elapsed))
|
logger.Info(fmt.Sprintf("Generate finished (%s)", elapsed))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) totalsGenerate {
|
func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
|
||||||
var totals totalsGenerate
|
j.totals = totalsGenerate{}
|
||||||
|
|
||||||
|
j.queueScenesTasks(ctx, g, queue)
|
||||||
|
j.queueImagesTasks(ctx, g, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
|
||||||
const batchSize = 1000
|
const batchSize = 1000
|
||||||
|
|
||||||
findFilter := models.BatchFindFilter(batchSize)
|
findFilter := models.BatchFindFilter(batchSize)
|
||||||
|
|
@ -234,26 +246,26 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que
|
||||||
|
|
||||||
for more := true; more; {
|
for more := true; more; {
|
||||||
if job.IsCancelled(ctx) {
|
if job.IsCancelled(ctx) {
|
||||||
return totals
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scenes, err := scene.Query(ctx, r.Scene, nil, findFilter)
|
scenes, err := scene.Query(ctx, r.Scene, nil, findFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
|
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
|
||||||
return totals
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ss := range scenes {
|
for _, ss := range scenes {
|
||||||
if job.IsCancelled(ctx) {
|
if job.IsCancelled(ctx) {
|
||||||
return totals
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ss.LoadFiles(ctx, r.Scene); err != nil {
|
if err := ss.LoadFiles(ctx, r.Scene); err != nil {
|
||||||
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
|
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
|
||||||
return totals
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
j.queueSceneJobs(ctx, g, ss, queue, &totals)
|
j.queueSceneJobs(ctx, g, ss, queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(scenes) != batchSize {
|
if len(scenes) != batchSize {
|
||||||
|
|
@ -262,30 +274,37 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que
|
||||||
*findFilter.Page++
|
*findFilter.Page++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
*findFilter.Page = 1
|
func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
|
||||||
for more := j.input.ClipPreviews; more; {
|
const batchSize = 1000
|
||||||
|
|
||||||
|
findFilter := models.BatchFindFilter(batchSize)
|
||||||
|
|
||||||
|
r := j.repository
|
||||||
|
|
||||||
|
for more := j.input.ClipPreviews || j.input.ImageThumbnails; more; {
|
||||||
if job.IsCancelled(ctx) {
|
if job.IsCancelled(ctx) {
|
||||||
return totals
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
images, err := image.Query(ctx, r.Image, nil, findFilter)
|
images, err := image.Query(ctx, r.Image, nil, findFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
|
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
|
||||||
return totals
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ss := range images {
|
for _, ss := range images {
|
||||||
if job.IsCancelled(ctx) {
|
if job.IsCancelled(ctx) {
|
||||||
return totals
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ss.LoadFiles(ctx, r.Image); err != nil {
|
if err := ss.LoadFiles(ctx, r.Image); err != nil {
|
||||||
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
|
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
|
||||||
return totals
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
j.queueImageJob(g, ss, queue, &totals)
|
j.queueImageJob(g, ss, queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(images) != batchSize {
|
if len(images) != batchSize {
|
||||||
|
|
@ -294,8 +313,6 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que
|
||||||
*findFilter.Page++
|
*findFilter.Page++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generate.PreviewOptions {
|
func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generate.PreviewOptions {
|
||||||
|
|
@ -333,7 +350,7 @@ func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generat
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task, totals *totalsGenerate) {
|
func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task) {
|
||||||
r := j.repository
|
r := j.repository
|
||||||
|
|
||||||
if j.input.Covers {
|
if j.input.Covers {
|
||||||
|
|
@ -344,8 +361,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.required(ctx) {
|
if task.required(ctx) {
|
||||||
totals.covers++
|
j.totals.covers++
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -358,8 +375,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.required() {
|
if task.required() {
|
||||||
totals.sprites++
|
j.totals.sprites++
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -382,13 +399,13 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||||
|
|
||||||
if task.required() {
|
if task.required() {
|
||||||
if task.videoPreviewRequired() {
|
if task.videoPreviewRequired() {
|
||||||
totals.previews++
|
j.totals.previews++
|
||||||
}
|
}
|
||||||
if task.imagePreviewRequired() {
|
if task.imagePreviewRequired() {
|
||||||
totals.imagePreviews++
|
j.totals.imagePreviews++
|
||||||
}
|
}
|
||||||
|
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -407,8 +424,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||||
|
|
||||||
markers := task.markersNeeded(ctx)
|
markers := task.markersNeeded(ctx)
|
||||||
if markers > 0 {
|
if markers > 0 {
|
||||||
totals.markers += int64(markers)
|
j.totals.markers += int64(markers)
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
|
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
|
|
@ -424,8 +441,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||||
g: g,
|
g: g,
|
||||||
}
|
}
|
||||||
if task.required() {
|
if task.required() {
|
||||||
totals.transcodes++
|
j.totals.transcodes++
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -441,8 +458,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.required() {
|
if task.required() {
|
||||||
totals.phashes++
|
j.totals.phashes++
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -457,14 +474,14 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.required() {
|
if task.required() {
|
||||||
totals.interactiveHeatmapSpeeds++
|
j.totals.interactiveHeatmapSpeeds++
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.SceneMarker, queue chan<- Task, totals *totalsGenerate) {
|
func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.SceneMarker, queue chan<- Task) {
|
||||||
task := &GenerateMarkersTask{
|
task := &GenerateMarkersTask{
|
||||||
repository: j.repository,
|
repository: j.repository,
|
||||||
Marker: marker,
|
Marker: marker,
|
||||||
|
|
@ -472,20 +489,35 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene
|
||||||
fileNamingAlgorithm: j.fileNamingAlgo,
|
fileNamingAlgorithm: j.fileNamingAlgo,
|
||||||
generator: g,
|
generator: g,
|
||||||
}
|
}
|
||||||
totals.markers++
|
j.totals.markers++
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task, totals *totalsGenerate) {
|
func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task) {
|
||||||
|
if j.input.ImageThumbnails {
|
||||||
|
task := &GenerateImageThumbnailTask{
|
||||||
|
Image: *image,
|
||||||
|
Overwrite: j.overwrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.required() {
|
||||||
|
j.totals.imageThumbnails++
|
||||||
|
j.totals.tasks++
|
||||||
|
queue <- task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.input.ClipPreviews {
|
||||||
task := &GenerateClipPreviewTask{
|
task := &GenerateClipPreviewTask{
|
||||||
Image: *image,
|
Image: *image,
|
||||||
Overwrite: j.overwrite,
|
Overwrite: j.overwrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.required() {
|
if task.required() {
|
||||||
totals.clipPreviews++
|
j.totals.clipPreviews++
|
||||||
totals.tasks++
|
j.totals.tasks++
|
||||||
queue <- task
|
queue <- task
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
internal/manager/task_generate_image_thumbnail.go
Normal file
79
internal/manager/task_generate_image_thumbnail.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/fsutil"
|
||||||
|
"github.com/stashapp/stash/pkg/image"
|
||||||
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenerateImageThumbnailTask struct {
|
||||||
|
Image models.Image
|
||||||
|
Overwrite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GenerateImageThumbnailTask) GetDescription() string {
|
||||||
|
return fmt.Sprintf("Generating Thumbnail for image %s", t.Image.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GenerateImageThumbnailTask) Start(ctx context.Context) {
|
||||||
|
if !t.required() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth)
|
||||||
|
f := t.Image.Files.Primary()
|
||||||
|
path := f.Base().Path
|
||||||
|
|
||||||
|
logger.Debugf("Generating thumbnail for %s", path)
|
||||||
|
|
||||||
|
mgr := GetInstance()
|
||||||
|
c := mgr.Config
|
||||||
|
|
||||||
|
clipPreviewOptions := image.ClipPreviewOptions{
|
||||||
|
InputArgs: c.GetTranscodeInputArgs(),
|
||||||
|
OutputArgs: c.GetTranscodeOutputArgs(),
|
||||||
|
Preset: c.GetPreviewPreset().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := image.NewThumbnailEncoder(mgr.FFMpeg, mgr.FFProbe, clipPreviewOptions)
|
||||||
|
data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// don't log for animated images
|
||||||
|
if !errors.Is(err, image.ErrNotSupportedForThumbnail) {
|
||||||
|
logger.Errorf("[generator] getting thumbnail for image %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fsutil.WriteFile(thumbPath, data)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[generator] writing thumbnail for image %s: %w", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *GenerateImageThumbnailTask) required() bool {
|
||||||
|
vf, ok := t.Image.Files.Primary().(models.VisualFile)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if vf.GetHeight() <= models.DefaultGthumbWidth && vf.GetWidth() <= models.DefaultGthumbWidth {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Overwrite {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth)
|
||||||
|
exists, _ := fsutil.FileExists(thumbPath)
|
||||||
|
|
||||||
|
return !exists
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package manager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -412,9 +411,12 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model
|
||||||
|
|
||||||
if t.ScanGenerateThumbnails {
|
if t.ScanGenerateThumbnails {
|
||||||
// this should be quick, so always generate sequentially
|
// this should be quick, so always generate sequentially
|
||||||
if err := g.generateThumbnail(ctx, i, f); err != nil {
|
taskThumbnail := GenerateImageThumbnailTask{
|
||||||
logger.Errorf("Error generating thumbnail for %s: %v", path, err)
|
Image: *i,
|
||||||
|
Overwrite: overwrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
taskThumbnail.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// avoid adding a task if the file isn't a video file
|
// avoid adding a task if the file isn't a video file
|
||||||
|
|
@ -446,54 +448,6 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *imageGenerators) generateThumbnail(ctx context.Context, i *models.Image, f models.File) error {
|
|
||||||
thumbPath := g.paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth)
|
|
||||||
exists, _ := fsutil.FileExists(thumbPath)
|
|
||||||
if exists {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
path := f.Base().Path
|
|
||||||
|
|
||||||
vf, ok := f.(models.VisualFile)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("file %s is not a visual file", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if vf.GetHeight() <= models.DefaultGthumbWidth && vf.GetWidth() <= models.DefaultGthumbWidth {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debugf("Generating thumbnail for %s", path)
|
|
||||||
|
|
||||||
mgr := GetInstance()
|
|
||||||
c := mgr.Config
|
|
||||||
|
|
||||||
clipPreviewOptions := image.ClipPreviewOptions{
|
|
||||||
InputArgs: c.GetTranscodeInputArgs(),
|
|
||||||
OutputArgs: c.GetTranscodeOutputArgs(),
|
|
||||||
Preset: c.GetPreviewPreset().String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
encoder := image.NewThumbnailEncoder(mgr.FFMpeg, mgr.FFProbe, clipPreviewOptions)
|
|
||||||
data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
// don't log for animated images
|
|
||||||
if !errors.Is(err, image.ErrNotSupportedForThumbnail) {
|
|
||||||
return fmt.Errorf("getting thumbnail for image %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fsutil.WriteFile(thumbPath, data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("writing thumbnail for image %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type sceneGenerators struct {
|
type sceneGenerators struct {
|
||||||
input ScanMetadataInput
|
input ScanMetadataInput
|
||||||
taskQueue *job.TaskQueue
|
taskQueue *job.TaskQueue
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ type GenerateMetadataOptions struct {
|
||||||
Transcodes bool `json:"transcodes"`
|
Transcodes bool `json:"transcodes"`
|
||||||
Phashes bool `json:"phashes"`
|
Phashes bool `json:"phashes"`
|
||||||
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
|
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
|
||||||
|
ImageThumbnails bool `json:"imageThumbnails"`
|
||||||
ClipPreviews bool `json:"clipPreviews"`
|
ClipPreviews bool `json:"clipPreviews"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {
|
||||||
phashes
|
phashes
|
||||||
interactiveHeatmapsSpeeds
|
interactiveHeatmapsSpeeds
|
||||||
clipPreviews
|
clipPreviews
|
||||||
|
imageThumbnails
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteFile
|
deleteFile
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,13 @@ import { SettingsContext } from "../Settings/context";
|
||||||
interface ISceneGenerateDialog {
|
interface ISceneGenerateDialog {
|
||||||
selectedIds?: string[];
|
selectedIds?: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
type: "scene"; // TODO - add image generate
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
||||||
selectedIds,
|
selectedIds,
|
||||||
onClose,
|
onClose,
|
||||||
|
type,
|
||||||
}) => {
|
}) => {
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
const { configuration } = React.useContext(ConfigurationContext);
|
||||||
|
|
||||||
|
|
@ -200,6 +202,7 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
||||||
<SettingsContext>
|
<SettingsContext>
|
||||||
<SettingSection>
|
<SettingSection>
|
||||||
<GenerateOptions
|
<GenerateOptions
|
||||||
|
type={type}
|
||||||
options={options}
|
options={options}
|
||||||
setOptions={setOptions}
|
setOptions={setOptions}
|
||||||
selection
|
selection
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsGenerateDialogOpen(false);
|
setIsGenerateDialogOpen(false);
|
||||||
}}
|
}}
|
||||||
|
type="scene"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@ export const SceneList: React.FC<ISceneList> = ({
|
||||||
if (isGenerateDialogOpen) {
|
if (isGenerateDialogOpen) {
|
||||||
return (
|
return (
|
||||||
<GenerateDialog
|
<GenerateDialog
|
||||||
|
type="scene"
|
||||||
selectedIds={Array.from(selectedIds.values())}
|
selectedIds={Array.from(selectedIds.values())}
|
||||||
onClose={() => setIsGenerateDialogOpen(false)}
|
onClose={() => setIsGenerateDialogOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ import {
|
||||||
} from "../GeneratePreviewOptions";
|
} from "../GeneratePreviewOptions";
|
||||||
|
|
||||||
interface IGenerateOptions {
|
interface IGenerateOptions {
|
||||||
|
type?: "scene" | "image";
|
||||||
selection?: boolean;
|
selection?: boolean;
|
||||||
options: GQL.GenerateMetadataInput;
|
options: GQL.GenerateMetadataInput;
|
||||||
setOptions: (s: GQL.GenerateMetadataInput) => void;
|
setOptions: (s: GQL.GenerateMetadataInput) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GenerateOptions: React.FC<IGenerateOptions> = ({
|
export const GenerateOptions: React.FC<IGenerateOptions> = ({
|
||||||
|
type,
|
||||||
selection,
|
selection,
|
||||||
options,
|
options,
|
||||||
setOptions: setOptionsState,
|
setOptions: setOptionsState,
|
||||||
|
|
@ -24,7 +26,12 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
|
||||||
setOptionsState({ ...options, ...input });
|
setOptionsState({ ...options, ...input });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showSceneOptions = !type || type === "scene";
|
||||||
|
const showImageOptions = !type || type === "image";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{showSceneOptions && (
|
||||||
<>
|
<>
|
||||||
<BooleanSetting
|
<BooleanSetting
|
||||||
id="covers-task"
|
id="covers-task"
|
||||||
|
|
@ -148,12 +155,24 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
|
||||||
headingID="dialogs.scene_gen.interactive_heatmap_speed"
|
headingID="dialogs.scene_gen.interactive_heatmap_speed"
|
||||||
onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })}
|
onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showImageOptions && (
|
||||||
|
<>
|
||||||
<BooleanSetting
|
<BooleanSetting
|
||||||
id="clip-previews"
|
id="clip-previews"
|
||||||
checked={options.clipPreviews ?? false}
|
checked={options.clipPreviews ?? false}
|
||||||
headingID="dialogs.scene_gen.clip_previews"
|
headingID="dialogs.scene_gen.clip_previews"
|
||||||
onChange={(v) => setOptions({ clipPreviews: v })}
|
onChange={(v) => setOptions({ clipPreviews: v })}
|
||||||
/>
|
/>
|
||||||
|
<BooleanSetting
|
||||||
|
id="image-thumbnails"
|
||||||
|
checked={options.imageThumbnails ?? false}
|
||||||
|
headingID="dialogs.scene_gen.image_thumbnails"
|
||||||
|
onChange={(v) => setOptions({ imageThumbnails: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<BooleanSetting
|
<BooleanSetting
|
||||||
id="overwrite"
|
id="overwrite"
|
||||||
checked={options.overwrite ?? false}
|
checked={options.overwrite ?? false}
|
||||||
|
|
|
||||||
|
|
@ -875,6 +875,7 @@
|
||||||
"force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.",
|
"force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.",
|
||||||
"image_previews": "Animated Image Previews",
|
"image_previews": "Animated Image Previews",
|
||||||
"image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
|
"image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
|
||||||
|
"image_thumbnails": "Image Thumbnails",
|
||||||
"interactive_heatmap_speed": "Generate heatmaps and speeds for interactive scenes",
|
"interactive_heatmap_speed": "Generate heatmaps and speeds for interactive scenes",
|
||||||
"marker_image_previews": "Marker Animated Image Previews",
|
"marker_image_previews": "Marker Animated Image Previews",
|
||||||
"marker_image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
|
"marker_image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue