From a8c909e0c98293cf1a817a7d94315785e86af5a3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 22 Feb 2024 11:19:23 +1100 Subject: [PATCH] Add option to generate image thumbnails during generate (#4602) * Add option to generate image thumbnails * Limit number of concurrent image thumbnail generation ops --- graphql/schema/types/metadata.graphql | 2 + internal/api/routes_image.go | 8 +- internal/manager/init.go | 3 + internal/manager/manager.go | 7 + internal/manager/task_generate.go | 126 +++++---- .../manager/task_generate_image_thumbnail.go | 79 ++++++ internal/manager/task_scan.go | 56 +--- pkg/models/generate.go | 1 + ui/v2.5/graphql/data/config.graphql | 1 + .../src/components/Dialogs/GenerateDialog.tsx | 3 + .../components/Scenes/SceneDetails/Scene.tsx | 1 + ui/v2.5/src/components/Scenes/SceneList.tsx | 1 + .../Settings/Tasks/GenerateOptions.tsx | 265 ++++++++++-------- ui/v2.5/src/locales/en-GB.json | 1 + 14 files changed, 332 insertions(+), 222 deletions(-) create mode 100644 internal/manager/task_generate_image_thumbnail.go diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 8a6dcbbc0..bb74a94dc 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -12,6 +12,7 @@ input GenerateMetadataInput { forceTranscodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean + imageThumbnails: Boolean clipPreviews: Boolean "scene ids to generate for" @@ -48,6 +49,7 @@ type GenerateMetadataOptions { transcodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean + imageThumbnails: Boolean clipPreviews: Boolean } diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 80c7880cf..270b4de7f 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -46,8 +46,9 @@ func (rs imageRoutes) Routes() chi.Router { } func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { + mgr := manager.GetInstance() 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 exists, _ := fsutil.FileExists(filepath) @@ -62,6 +63,11 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { 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{ InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(), OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(), diff --git a/internal/manager/init.go b/internal/manager/init.go index 1dc8e3012..a2e558b6a 100644 --- a/internal/manager/init.go +++ b/internal/manager/init.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/desktop" "github.com/stashapp/stash/internal/dlna" "github.com/stashapp/stash/internal/log" @@ -80,6 +81,8 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { Paths: mgrPaths, + ImageThumbnailGenerateWaitGroup: sizedwaitgroup.New(1), + JobManager: initJobManager(cfg), ReadLockManager: fsutil.NewReadLockManager(), diff --git a/internal/manager/manager.go b/internal/manager/manager.go index db88d45ac..cff2b439c 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -10,6 +10,7 @@ import ( "runtime" "time" + "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/dlna" "github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/manager/config" @@ -33,6 +34,10 @@ type Manager struct { Config *config.Config 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 FFMpeg *ffmpeg.FFMpeg @@ -107,6 +112,8 @@ func (s *Manager) RefreshConfig() { if err := fsutil.EnsureDir(s.Paths.Generated.InteractiveHeatmap); err != nil { logger.Warnf("could not create interactive heatmaps directory: %v", err) } + + s.ImageThumbnailGenerateWaitGroup.Size = cfg.GetParallelTasksWithAutoDetection() } } diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index 89ba668ae..5d8ebdac9 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -31,6 +31,7 @@ type GenerateMetadataInput struct { Phashes bool `json:"phashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` ClipPreviews bool `json:"clipPreviews"` + ImageThumbnails bool `json:"imageThumbnails"` // scene ids to generate for SceneIDs []string `json:"sceneIDs"` // marker ids to generate for @@ -60,6 +61,8 @@ type GenerateJob struct { overwrite bool fileNamingAlgo models.HashAlgorithm + + totals totalsGenerate } type totalsGenerate struct { @@ -72,6 +75,7 @@ type totalsGenerate struct { phashes int64 interactiveHeatmapSpeeds int64 clipPreviews int64 + imageThumbnails int64 tasks int } @@ -93,7 +97,6 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { go func() { defer close(queue) - var totals totalsGenerate sceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs) if err != nil { 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 { qb := r.Scene if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 { - totals = j.queueTasks(ctx, g, queue) + j.queueTasks(ctx, g, queue) } else { if len(j.input.SceneIDs) > 0 { scenes, err = qb.FindMany(ctx, sceneIDs) @@ -125,7 +128,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { 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 } 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 } + totals := j.totals logMsg := "Generating" if j.input.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 { logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews) } + if j.input.ImageThumbnails { + logMsg += fmt.Sprintf(" %d Image Thumbnails", totals.imageThumbnails) + } if logMsg == "Generating" { 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)) } -func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) totalsGenerate { - var totals totalsGenerate +func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { + 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 findFilter := models.BatchFindFilter(batchSize) @@ -234,26 +246,26 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que for more := true; more; { if job.IsCancelled(ctx) { - return totals + return } scenes, err := scene.Query(ctx, r.Scene, nil, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) - return totals + return } for _, ss := range scenes { if job.IsCancelled(ctx) { - return totals + return } if err := ss.LoadFiles(ctx, r.Scene); err != nil { 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 { @@ -262,30 +274,37 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que *findFilter.Page++ } } +} - *findFilter.Page = 1 - for more := j.input.ClipPreviews; more; { +func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { + const batchSize = 1000 + + findFilter := models.BatchFindFilter(batchSize) + + r := j.repository + + for more := j.input.ClipPreviews || j.input.ImageThumbnails; more; { if job.IsCancelled(ctx) { - return totals + return } images, err := image.Query(ctx, r.Image, nil, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) - return totals + return } for _, ss := range images { if job.IsCancelled(ctx) { - return totals + return } if err := ss.LoadFiles(ctx, r.Image); err != nil { 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 { @@ -294,8 +313,6 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que *findFilter.Page++ } } - - return totals } func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generate.PreviewOptions { @@ -333,7 +350,7 @@ func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generat 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 if j.input.Covers { @@ -344,8 +361,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } if task.required(ctx) { - totals.covers++ - totals.tasks++ + j.totals.covers++ + j.totals.tasks++ queue <- task } } @@ -358,8 +375,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } if task.required() { - totals.sprites++ - totals.tasks++ + j.totals.sprites++ + j.totals.tasks++ queue <- task } } @@ -382,13 +399,13 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, if task.required() { if task.videoPreviewRequired() { - totals.previews++ + j.totals.previews++ } if task.imagePreviewRequired() { - totals.imagePreviews++ + j.totals.imagePreviews++ } - totals.tasks++ + j.totals.tasks++ queue <- task } } @@ -407,8 +424,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, markers := task.markersNeeded(ctx) if markers > 0 { - totals.markers += int64(markers) - totals.tasks++ + j.totals.markers += int64(markers) + j.totals.tasks++ queue <- task } @@ -424,8 +441,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, g: g, } if task.required() { - totals.transcodes++ - totals.tasks++ + j.totals.transcodes++ + j.totals.tasks++ queue <- task } } @@ -441,8 +458,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } if task.required() { - totals.phashes++ - totals.tasks++ + j.totals.phashes++ + j.totals.tasks++ queue <- task } } @@ -457,14 +474,14 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } if task.required() { - totals.interactiveHeatmapSpeeds++ - totals.tasks++ + j.totals.interactiveHeatmapSpeeds++ + j.totals.tasks++ 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{ repository: j.repository, Marker: marker, @@ -472,20 +489,35 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene fileNamingAlgorithm: j.fileNamingAlgo, generator: g, } - totals.markers++ - totals.tasks++ + j.totals.markers++ + j.totals.tasks++ queue <- task } -func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task, totals *totalsGenerate) { - task := &GenerateClipPreviewTask{ - Image: *image, - Overwrite: j.overwrite, +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 task.required() { - totals.clipPreviews++ - totals.tasks++ - queue <- task + if j.input.ClipPreviews { + task := &GenerateClipPreviewTask{ + Image: *image, + Overwrite: j.overwrite, + } + + if task.required() { + j.totals.clipPreviews++ + j.totals.tasks++ + queue <- task + } } } diff --git a/internal/manager/task_generate_image_thumbnail.go b/internal/manager/task_generate_image_thumbnail.go new file mode 100644 index 000000000..2d32e2d60 --- /dev/null +++ b/internal/manager/task_generate_image_thumbnail.go @@ -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 +} diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 342b5c133..2b01d9c91 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -2,7 +2,6 @@ package manager import ( "context" - "errors" "fmt" "io/fs" "path/filepath" @@ -412,9 +411,12 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model if t.ScanGenerateThumbnails { // this should be quick, so always generate sequentially - if err := g.generateThumbnail(ctx, i, f); err != nil { - logger.Errorf("Error generating thumbnail for %s: %v", path, err) + taskThumbnail := GenerateImageThumbnailTask{ + Image: *i, + Overwrite: overwrite, } + + taskThumbnail.Start(ctx) } // 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 } -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 { input ScanMetadataInput taskQueue *job.TaskQueue diff --git a/pkg/models/generate.go b/pkg/models/generate.go index c8fa9785c..5afcea4a1 100644 --- a/pkg/models/generate.go +++ b/pkg/models/generate.go @@ -18,6 +18,7 @@ type GenerateMetadataOptions struct { Transcodes bool `json:"transcodes"` Phashes bool `json:"phashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` + ImageThumbnails bool `json:"imageThumbnails"` ClipPreviews bool `json:"clipPreviews"` } diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index 32857dd80..160234d6e 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -196,6 +196,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { phashes interactiveHeatmapsSpeeds clipPreviews + imageThumbnails } deleteFile diff --git a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx index 186d64e2e..669bc8aa4 100644 --- a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx @@ -17,11 +17,13 @@ import { SettingsContext } from "../Settings/context"; interface ISceneGenerateDialog { selectedIds?: string[]; onClose: () => void; + type: "scene"; // TODO - add image generate } export const GenerateDialog: React.FC = ({ selectedIds, onClose, + type, }) => { const { configuration } = React.useContext(ConfigurationContext); @@ -200,6 +202,7 @@ export const GenerateDialog: React.FC = ({ = ({ onClose={() => { setIsGenerateDialogOpen(false); }} + type="scene" /> ); } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 3156a8a25..89c52f429 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -235,6 +235,7 @@ export const SceneList: React.FC = ({ if (isGenerateDialogOpen) { return ( setIsGenerateDialogOpen(false)} /> diff --git a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx index 77020e3b3..c0127b5db 100644 --- a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx @@ -7,12 +7,14 @@ import { } from "../GeneratePreviewOptions"; interface IGenerateOptions { + type?: "scene" | "image"; selection?: boolean; options: GQL.GenerateMetadataInput; setOptions: (s: GQL.GenerateMetadataInput) => void; } export const GenerateOptions: React.FC = ({ + type, selection, options, setOptions: setOptionsState, @@ -24,136 +26,153 @@ export const GenerateOptions: React.FC = ({ setOptionsState({ ...options, ...input }); } + const showSceneOptions = !type || type === "scene"; + const showImageOptions = !type || type === "image"; + return ( <> - setOptions({ covers: v })} - /> - setOptions({ previews: v })} - /> - setOptions({ imagePreviews: v })} - /> + {showSceneOptions && ( + <> + setOptions({ covers: v })} + /> + setOptions({ previews: v })} + /> + setOptions({ imagePreviews: v })} + /> - {/* #2251 - only allow preview generation options to be overridden when generating from a selection */} - {selection ? ( - - id="video-preview-settings" - className="sub-setting" - disabled={!options.previews} - headingID="dialogs.scene_gen.override_preview_generation_options" - tooltipID="dialogs.scene_gen.override_preview_generation_options_desc" - value={{ - previewExcludeEnd: previewOptions.previewExcludeEnd, - previewExcludeStart: previewOptions.previewExcludeStart, - previewSegmentDuration: previewOptions.previewSegmentDuration, - previewSegments: previewOptions.previewSegments, - }} - onChange={(v) => setOptions({ previewOptions: v })} - renderField={(value, setValue) => ( - - )} - renderValue={() => { - return <>; - }} - /> - ) : undefined} + {/* #2251 - only allow preview generation options to be overridden when generating from a selection */} + {selection ? ( + + id="video-preview-settings" + className="sub-setting" + disabled={!options.previews} + headingID="dialogs.scene_gen.override_preview_generation_options" + tooltipID="dialogs.scene_gen.override_preview_generation_options_desc" + value={{ + previewExcludeEnd: previewOptions.previewExcludeEnd, + previewExcludeStart: previewOptions.previewExcludeStart, + previewSegmentDuration: previewOptions.previewSegmentDuration, + previewSegments: previewOptions.previewSegments, + }} + onChange={(v) => setOptions({ previewOptions: v })} + renderField={(value, setValue) => ( + + )} + renderValue={() => { + return <>; + }} + /> + ) : undefined} - setOptions({ sprites: v })} - /> - setOptions({ markers: v })} - /> - - setOptions({ - markerImagePreviews: v, - }) - } - /> - setOptions({ markerScreenshots: v })} - /> + setOptions({ sprites: v })} + /> + setOptions({ markers: v })} + /> + + setOptions({ + markerImagePreviews: v, + }) + } + /> + setOptions({ markerScreenshots: v })} + /> - setOptions({ transcodes: v })} - /> - {selection ? ( - setOptions({ forceTranscodes: v })} - /> - ) : undefined} + setOptions({ transcodes: v })} + /> + {selection ? ( + setOptions({ forceTranscodes: v })} + /> + ) : undefined} - setOptions({ phashes: v })} - /> + setOptions({ phashes: v })} + /> - setOptions({ interactiveHeatmapsSpeeds: v })} - /> - setOptions({ clipPreviews: v })} - /> + setOptions({ interactiveHeatmapsSpeeds: v })} + /> + + )} + {showImageOptions && ( + <> + setOptions({ clipPreviews: v })} + /> + setOptions({ imageThumbnails: v })} + /> + + )}