From c104c6d075edab7a5d3ff1c3ec09d81566cc7c8c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sun, 19 Jul 2020 11:59:18 +1000 Subject: [PATCH] Generate content for specific scenes (#672) * Add UI dialog for scene(s) * Move preview preset to config --- graphql/documents/data/config.graphql | 1 + graphql/schema/types/config.graphql | 14 ++ graphql/schema/types/metadata.graphql | 21 ++- pkg/api/resolver_mutation_configure.go | 4 + pkg/api/resolver_mutation_metadata.go | 2 +- pkg/api/resolver_query_configuration.go | 1 + pkg/manager/config/config.go | 15 +++ pkg/manager/generator_preview.go | 11 +- pkg/manager/generator_sprite.go | 17 ++- pkg/manager/manager_tasks.go | 122 +++++++++++++----- pkg/manager/task_generate_gallery_thumbs.go | 8 +- pkg/manager/task_generate_markers.go | 98 +++++++++----- pkg/manager/task_generate_preview.go | 9 +- pkg/manager/task_generate_sprite.go | 9 +- pkg/manager/task_transcode.go | 7 +- pkg/models/querybuilder_gallery.go | 19 +++ pkg/models/querybuilder_scene.go | 19 +++ pkg/models/querybuilder_scene_marker.go | 22 +++- pkg/utils/string_collections.go | 14 ++ .../components/Changelog/versions/v030.tsx | 1 + ui/v2.5/src/components/List/ListFilter.tsx | 31 +++-- .../components/Scenes/SceneDetails/Scene.tsx | 23 ++++ .../components/Scenes/SceneGenerateDialog.tsx | 105 +++++++++++++++ ui/v2.5/src/components/Scenes/SceneList.tsx | 46 ++++++- .../Settings/SettingsConfigurationPanel.tsx | 31 ++++- .../SettingsTasksPanel/GenerateButton.tsx | 30 ----- ui/v2.5/src/hooks/ListHook.tsx | 20 +++ 27 files changed, 552 insertions(+), 148 deletions(-) create mode 100644 ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index e050bfdd1..8a2c206b9 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -3,6 +3,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { databasePath generatedPath cachePath + previewPreset maxTranscodeSize maxStreamingTranscodeSize forceMkv diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 0f65c97fb..bed71fc08 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -7,6 +7,16 @@ enum StreamingResolutionEnum { "Original", ORIGINAL } +enum PreviewPreset { + "X264_ULTRAFAST", ultrafast + "X264_VERYFAST", veryfast + "X264_FAST", fast + "X264_MEDIUM", medium + "X264_SLOW", slow + "X264_SLOWER", slower + "X264_VERYSLOW", veryslow +} + input ConfigGeneralInput { """Array of file paths to content""" stashes: [String!] @@ -16,6 +26,8 @@ input ConfigGeneralInput { generatedPath: String """Path to cache""" cachePath: String + """Preset when generating preview""" + previewPreset: PreviewPreset """Max generated transcode size""" maxTranscodeSize: StreamingResolutionEnum """Max streaming transcode size""" @@ -53,6 +65,8 @@ type ConfigGeneralResult { generatedPath: String! """Path to cache""" cachePath: String! + """Preset when generating preview""" + previewPreset: PreviewPreset! """Max generated transcode size""" maxTranscodeSize: StreamingResolutionEnum """Max streaming transcode size""" diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index dede131fc..8bb868255 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -1,12 +1,21 @@ input GenerateMetadataInput { sprites: Boolean! previews: Boolean! - previewPreset: PreviewPreset imagePreviews: Boolean! markers: Boolean! transcodes: Boolean! """gallery thumbnails for cache usage""" thumbnails: Boolean! + + """scene ids to generate for""" + sceneIDs: [ID!] + """marker ids to generate for""" + markerIDs: [ID!] + """gallery ids to generate for""" + galleryIDs: [ID!] + + """overwrite existing media""" + overwrite: Boolean } input ScanMetadataInput { @@ -27,13 +36,3 @@ type MetadataUpdateStatus { status: String! message: String! } - -enum PreviewPreset { - "X264_ULTRAFAST", ultrafast - "X264_VERYFAST", veryfast - "X264_FAST", fast - "X264_MEDIUM", medium - "X264_SLOW", slow - "X264_SLOWER", slower - "X264_VERYSLOW", veryslow -} diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index ab7deb743..196d169c5 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -45,6 +45,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co config.Set(config.Cache, input.CachePath) } + if input.PreviewPreset != nil { + config.Set(config.PreviewPreset, input.PreviewPreset.String()) + } + if input.MaxTranscodeSize != nil { config.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) } diff --git a/pkg/api/resolver_mutation_metadata.go b/pkg/api/resolver_mutation_metadata.go index 65a743af8..725138c89 100644 --- a/pkg/api/resolver_mutation_metadata.go +++ b/pkg/api/resolver_mutation_metadata.go @@ -23,7 +23,7 @@ func (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) { } func (r *mutationResolver) MetadataGenerate(ctx context.Context, input models.GenerateMetadataInput) (string, error) { - manager.GetInstance().Generate(input.Sprites, input.Previews, input.PreviewPreset, input.ImagePreviews, input.Markers, input.Transcodes, input.Thumbnails) + manager.GetInstance().Generate(input) return "todo", nil } diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index da059de60..9e027481a 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -46,6 +46,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult { DatabasePath: config.GetDatabasePath(), GeneratedPath: config.GetGeneratedPath(), CachePath: config.GetCachePath(), + PreviewPreset: config.GetPreviewPreset(), MaxTranscodeSize: &maxTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, ForceMkv: config.GetForceMKV(), diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 9c6908542..31cc18b97 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -27,6 +27,8 @@ const Database = "database" const Exclude = "exclude" +const PreviewPreset = "preview_preset" + const MaxTranscodeSize = "max_transcode_size" const MaxStreamingTranscodeSize = "max_streaming_transcode_size" @@ -160,6 +162,19 @@ func GetExternalHost() string { return viper.GetString(ExternalHost) } +// GetPreviewPreset returns the preset when generating previews. Defaults to +// Slow. +func GetPreviewPreset() models.PreviewPreset { + ret := viper.GetString(PreviewPreset) + + // default to slow + if ret == "" { + return models.PreviewPresetSlow + } + + return models.PreviewPreset(ret) +} + func GetMaxTranscodeSize() models.StreamingResolutionEnum { ret := viper.GetString(MaxTranscodeSize) diff --git a/pkg/manager/generator_preview.go b/pkg/manager/generator_preview.go index f91fdf956..a1a6c173e 100644 --- a/pkg/manager/generator_preview.go +++ b/pkg/manager/generator_preview.go @@ -3,11 +3,12 @@ package manager import ( "bufio" "fmt" + "os" + "path/filepath" + "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/utils" - "os" - "path/filepath" ) type PreviewGenerator struct { @@ -21,6 +22,8 @@ type PreviewGenerator struct { GenerateImage bool PreviewPreset string + + Overwrite bool } func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, imageFilename string, outputDirectory string, generateVideo bool, generateImage bool, previewPreset string) (*PreviewGenerator, error) { @@ -88,7 +91,7 @@ func (g *PreviewGenerator) generateConcatFile() error { func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder) error { outputPath := filepath.Join(g.OutputDirectory, g.VideoFilename) outputExists, _ := utils.FileExists(outputPath) - if outputExists { + if !g.Overwrite && outputExists { return nil } @@ -116,7 +119,7 @@ func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder) error { func (g *PreviewGenerator) generateImage(encoder *ffmpeg.Encoder) error { outputPath := filepath.Join(g.OutputDirectory, g.ImageFilename) outputExists, _ := utils.FileExists(outputPath) - if outputExists { + if !g.Overwrite && outputExists { return nil } diff --git a/pkg/manager/generator_sprite.go b/pkg/manager/generator_sprite.go index cc3b57f95..96578c08b 100644 --- a/pkg/manager/generator_sprite.go +++ b/pkg/manager/generator_sprite.go @@ -2,17 +2,18 @@ package manager import ( "fmt" - "github.com/bmatcuk/doublestar" - "github.com/disintegration/imaging" - "github.com/stashapp/stash/pkg/ffmpeg" - "github.com/stashapp/stash/pkg/logger" - "github.com/stashapp/stash/pkg/utils" "image" "image/color" "io/ioutil" "math" "path/filepath" "strings" + + "github.com/bmatcuk/doublestar" + "github.com/disintegration/imaging" + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/utils" ) type SpriteGenerator struct { @@ -22,6 +23,8 @@ type SpriteGenerator struct { VTTOutputPath string Rows int Columns int + + Overwrite bool } func NewSpriteGenerator(videoFile ffmpeg.VideoFile, imageOutputPath string, vttOutputPath string, rows int, cols int) (*SpriteGenerator, error) { @@ -60,7 +63,7 @@ func (g *SpriteGenerator) Generate() error { } func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { - if g.imageExists() { + if !g.Overwrite && g.imageExists() { return nil } logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) @@ -112,7 +115,7 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { } func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error { - if g.vttExists() { + if !g.Overwrite && g.vttExists() { return nil } logger.Infof("[generator] generating sprite vtt for %s", g.Info.VideoFile.Path) diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index 25713d7c9..335a3097e 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -167,7 +167,7 @@ func (s *singleton) Export() { }() } -func (s *singleton) Generate(sprites bool, previews bool, previewPreset *models.PreviewPreset, imagePreviews bool, markers bool, transcodes bool, thumbnails bool) { +func (s *singleton) Generate(input models.GenerateMetadataInput) { if s.Status.Status != Idle { return } @@ -176,32 +176,49 @@ func (s *singleton) Generate(sprites bool, previews bool, previewPreset *models. qb := models.NewSceneQueryBuilder() qg := models.NewGalleryQueryBuilder() + mqb := models.NewSceneMarkerQueryBuilder() + //this.job.total = await ObjectionUtils.getCount(Scene); instance.Paths.Generated.EnsureTmpDir() - preset := string(models.PreviewPresetSlow) - if previewPreset != nil && previewPreset.IsValid() { - preset = string(*previewPreset) - } + preset := config.GetPreviewPreset().String() + + galleryIDs := utils.StringSliceToIntSlice(input.GalleryIDs) + sceneIDs := utils.StringSliceToIntSlice(input.SceneIDs) + markerIDs := utils.StringSliceToIntSlice(input.MarkerIDs) go func() { defer s.returnToIdleState() - scenes, err := qb.All() - var galleries []*models.Gallery + var scenes []*models.Scene + var err error + + if len(sceneIDs) > 0 { + scenes, err = qb.FindMany(sceneIDs) + } else { + scenes, err = qb.All() + } if err != nil { logger.Errorf("failed to get scenes for generate") return } - delta := utils.Btoi(sprites) + utils.Btoi(previews) + utils.Btoi(markers) + utils.Btoi(transcodes) + delta := utils.Btoi(input.Sprites) + utils.Btoi(input.Previews) + utils.Btoi(input.Markers) + utils.Btoi(input.Transcodes) var wg sync.WaitGroup + s.Status.Progress = 0 lenScenes := len(scenes) total := lenScenes - if thumbnails { - galleries, err = qg.All() + + var galleries []*models.Gallery + if input.Thumbnails { + if len(galleryIDs) > 0 { + galleries, err = qg.FindMany(galleryIDs) + } else { + galleries, err = qg.All() + } + if err != nil { logger.Errorf("failed to get galleries for generate") return @@ -209,17 +226,31 @@ func (s *singleton) Generate(sprites bool, previews bool, previewPreset *models. total += len(galleries) } + var markers []*models.SceneMarker + if len(markerIDs) > 0 { + markers, err = mqb.FindMany(markerIDs) + + total += len(markers) + } + if s.Status.stopping { logger.Info("Stopping due to user request") return } - totalsNeeded := s.neededGenerate(scenes, sprites, previews, imagePreviews, markers, transcodes) + + totalsNeeded := s.neededGenerate(scenes, input) if totalsNeeded == nil { logger.Infof("Taking too long to count content. Skipping...") logger.Infof("Generating content") } else { logger.Infof("Generating %d sprites %d previews %d image previews %d markers %d transcodes", totalsNeeded.sprites, totalsNeeded.previews, totalsNeeded.imagePreviews, totalsNeeded.markers, totalsNeeded.transcodes) } + + overwrite := false + if input.Overwrite != nil { + overwrite = *input.Overwrite + } + for i, scene := range scenes { s.Status.setProgress(i, total) if s.Status.stopping { @@ -235,34 +266,34 @@ func (s *singleton) Generate(sprites bool, previews bool, previewPreset *models. wg.Add(delta) // Clear the tmp directory for each scene - if sprites || previews || markers { + if input.Sprites || input.Previews || input.Markers { instance.Paths.Generated.EmptyTmpDir() } - if sprites { - task := GenerateSpriteTask{Scene: *scene} + if input.Sprites { + task := GenerateSpriteTask{Scene: *scene, Overwrite: overwrite} go task.Start(&wg) } - if previews { - task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews, PreviewPreset: preset} + if input.Previews { + task := GeneratePreviewTask{Scene: *scene, ImagePreview: input.ImagePreviews, PreviewPreset: preset, Overwrite: overwrite} go task.Start(&wg) } - if markers { - task := GenerateMarkersTask{Scene: *scene} + if input.Markers { + task := GenerateMarkersTask{Scene: scene, Overwrite: overwrite} go task.Start(&wg) } - if transcodes { - task := GenerateTranscodeTask{Scene: *scene} + if input.Transcodes { + task := GenerateTranscodeTask{Scene: *scene, Overwrite: overwrite} go task.Start(&wg) } wg.Wait() } - if thumbnails { + if input.Thumbnails { logger.Infof("Generating thumbnails for the galleries") for i, gallery := range galleries { s.Status.setProgress(lenScenes+i, total) @@ -277,12 +308,30 @@ func (s *singleton) Generate(sprites bool, previews bool, previewPreset *models. } wg.Add(1) - task := GenerateGthumbsTask{Gallery: *gallery} + task := GenerateGthumbsTask{Gallery: *gallery, Overwrite: overwrite} go task.Start(&wg) wg.Wait() } } + for i, marker := range markers { + s.Status.setProgress(lenScenes+len(galleries)+i, total) + if s.Status.stopping { + logger.Info("Stopping due to user request") + return + } + + if marker == nil { + logger.Errorf("nil marker, skipping generate") + continue + } + + wg.Add(1) + task := GenerateMarkersTask{Marker: marker, Overwrite: overwrite} + go task.Start(&wg) + wg.Wait() + } + logger.Infof("Generate finished") }() } @@ -610,7 +659,7 @@ type totalsGenerate struct { transcodes int64 } -func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, imagePreviews, markers, transcodes bool) *totalsGenerate { +func (s *singleton) neededGenerate(scenes []*models.Scene, input models.GenerateMetadataInput) *totalsGenerate { var totals totalsGenerate const timeout = 90 * time.Second @@ -624,33 +673,38 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, im chTimeout <- struct{}{} }() + overwrite := false + if input.Overwrite != nil { + overwrite = *input.Overwrite + } + logger.Infof("Counting content to generate...") for _, scene := range scenes { if scene != nil { - if sprites { + if input.Sprites { task := GenerateSpriteTask{Scene: *scene} - if !task.doesSpriteExist(task.Scene.Checksum) { + if overwrite || !task.doesSpriteExist(task.Scene.Checksum) { totals.sprites++ } } - if previews { - task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews} - if !task.doesVideoPreviewExist(task.Scene.Checksum) { + if input.Previews { + task := GeneratePreviewTask{Scene: *scene, ImagePreview: input.ImagePreviews} + if overwrite || !task.doesVideoPreviewExist(task.Scene.Checksum) { totals.previews++ } - if imagePreviews && !task.doesImagePreviewExist(task.Scene.Checksum) { + if input.ImagePreviews && (overwrite || !task.doesImagePreviewExist(task.Scene.Checksum)) { totals.imagePreviews++ } } - if markers { - task := GenerateMarkersTask{Scene: *scene} + if input.Markers { + task := GenerateMarkersTask{Scene: scene, Overwrite: overwrite} totals.markers += int64(task.isMarkerNeeded()) - } - if transcodes { - task := GenerateTranscodeTask{Scene: *scene} + + if input.Transcodes { + task := GenerateTranscodeTask{Scene: *scene, Overwrite: overwrite} if task.isTranscodeNeeded() { totals.transcodes++ } diff --git a/pkg/manager/task_generate_gallery_thumbs.go b/pkg/manager/task_generate_gallery_thumbs.go index 2079e980d..6aad9c80d 100644 --- a/pkg/manager/task_generate_gallery_thumbs.go +++ b/pkg/manager/task_generate_gallery_thumbs.go @@ -1,15 +1,17 @@ package manager import ( + "sync" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/paths" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" - "sync" ) type GenerateGthumbsTask struct { - Gallery models.Gallery + Gallery models.Gallery + Overwrite bool } func (t *GenerateGthumbsTask) Start(wg *sync.WaitGroup) { @@ -19,7 +21,7 @@ func (t *GenerateGthumbsTask) Start(wg *sync.WaitGroup) { for i := 0; i < count; i++ { thumbPath := paths.GetGthumbPath(t.Gallery.Checksum, i, models.DefaultGthumbWidth) exists, _ := utils.FileExists(thumbPath) - if exists { + if !t.Overwrite && exists { continue } data := t.Gallery.GetThumbnail(i, models.DefaultGthumbWidth) diff --git a/pkg/manager/task_generate_markers.go b/pkg/manager/task_generate_markers.go index 2b6cde2c6..4da78e82b 100644 --- a/pkg/manager/task_generate_markers.go +++ b/pkg/manager/task_generate_markers.go @@ -13,12 +13,37 @@ import ( ) type GenerateMarkersTask struct { - Scene models.Scene + Scene *models.Scene + Marker *models.SceneMarker + Overwrite bool } func (t *GenerateMarkersTask) Start(wg *sync.WaitGroup) { defer wg.Done() + if t.Scene != nil { + t.generateSceneMarkers() + } + + if t.Marker != nil { + qb := models.NewSceneQueryBuilder() + scene, err := qb.Find(int(t.Marker.SceneID.Int64)) + if err != nil { + logger.Errorf("error finding scene for marker: %s", err.Error()) + return + } + + videoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, t.Scene.Path) + if err != nil { + logger.Errorf("error reading video file: %s", err.Error()) + return + } + + t.generateMarker(videoFile, scene, t.Marker) + } +} + +func (t *GenerateMarkersTask) generateSceneMarkers() { qb := models.NewSceneMarkerQueryBuilder() sceneMarkers, _ := qb.FindBySceneID(t.Scene.ID, nil) if len(sceneMarkers) == 0 { @@ -35,43 +60,49 @@ func (t *GenerateMarkersTask) Start(wg *sync.WaitGroup) { markersFolder := filepath.Join(instance.Paths.Generated.Markers, t.Scene.Checksum) _ = utils.EnsureDir(markersFolder) - encoder := ffmpeg.NewEncoder(instance.FFMPEGPath) for i, sceneMarker := range sceneMarkers { index := i + 1 logger.Progressf("[generator] <%s> scene marker %d of %d", t.Scene.Checksum, index, len(sceneMarkers)) - seconds := int(sceneMarker.Seconds) - baseFilename := strconv.Itoa(seconds) - videoFilename := baseFilename + ".mp4" - imageFilename := baseFilename + ".webp" - videoPath := instance.Paths.SceneMarkers.GetStreamPath(t.Scene.Checksum, seconds) - imagePath := instance.Paths.SceneMarkers.GetStreamPreviewImagePath(t.Scene.Checksum, seconds) - videoExists, _ := utils.FileExists(videoPath) - imageExists, _ := utils.FileExists(imagePath) + t.generateMarker(videoFile, t.Scene, sceneMarker) + } +} - options := ffmpeg.SceneMarkerOptions{ - ScenePath: t.Scene.Path, - Seconds: seconds, - Width: 640, - } - if !videoExists { - options.OutputPath = instance.Paths.Generated.GetTmpPath(videoFilename) // tmp output in case the process ends abruptly - if err := encoder.SceneMarkerVideo(*videoFile, options); err != nil { - logger.Errorf("[generator] failed to generate marker video: %s", err) - } else { - _ = os.Rename(options.OutputPath, videoPath) - logger.Debug("created marker video: ", videoPath) - } - } +func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) { + seconds := int(sceneMarker.Seconds) + baseFilename := strconv.Itoa(seconds) + videoFilename := baseFilename + ".mp4" + imageFilename := baseFilename + ".webp" + videoPath := instance.Paths.SceneMarkers.GetStreamPath(scene.Checksum, seconds) + imagePath := instance.Paths.SceneMarkers.GetStreamPreviewImagePath(scene.Checksum, seconds) + videoExists, _ := utils.FileExists(videoPath) + imageExists, _ := utils.FileExists(imagePath) - if !imageExists { - options.OutputPath = instance.Paths.Generated.GetTmpPath(imageFilename) // tmp output in case the process ends abruptly - if err := encoder.SceneMarkerImage(*videoFile, options); err != nil { - logger.Errorf("[generator] failed to generate marker image: %s", err) - } else { - _ = os.Rename(options.OutputPath, imagePath) - logger.Debug("created marker image: ", videoPath) - } + options := ffmpeg.SceneMarkerOptions{ + ScenePath: scene.Path, + Seconds: seconds, + Width: 640, + } + + encoder := ffmpeg.NewEncoder(instance.FFMPEGPath) + + if t.Overwrite || !videoExists { + options.OutputPath = instance.Paths.Generated.GetTmpPath(videoFilename) // tmp output in case the process ends abruptly + if err := encoder.SceneMarkerVideo(*videoFile, options); err != nil { + logger.Errorf("[generator] failed to generate marker video: %s", err) + } else { + _ = os.Rename(options.OutputPath, videoPath) + logger.Debug("created marker video: ", videoPath) + } + } + + if t.Overwrite || !imageExists { + options.OutputPath = instance.Paths.Generated.GetTmpPath(imageFilename) // tmp output in case the process ends abruptly + if err := encoder.SceneMarkerImage(*videoFile, options); err != nil { + logger.Errorf("[generator] failed to generate marker image: %s", err) + } else { + _ = os.Rename(options.OutputPath, imagePath) + logger.Debug("created marker image: ", videoPath) } } } @@ -92,10 +123,11 @@ func (t *GenerateMarkersTask) isMarkerNeeded() int { videoExists, _ := utils.FileExists(videoPath) imageExists, _ := utils.FileExists(imagePath) - if (!videoExists) || (!imageExists) { + if t.Overwrite || !videoExists || !imageExists { markers++ } } + return markers } diff --git a/pkg/manager/task_generate_preview.go b/pkg/manager/task_generate_preview.go index 4aba82095..492d16839 100644 --- a/pkg/manager/task_generate_preview.go +++ b/pkg/manager/task_generate_preview.go @@ -1,17 +1,19 @@ package manager import ( + "sync" + "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" - "sync" ) type GeneratePreviewTask struct { Scene models.Scene ImagePreview bool PreviewPreset string + Overwrite bool } func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) { @@ -20,7 +22,7 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) { videoFilename := t.videoFilename() imageFilename := t.imageFilename() videoExists := t.doesVideoPreviewExist(t.Scene.Checksum) - if (!t.ImagePreview || t.doesImagePreviewExist(t.Scene.Checksum)) && videoExists { + if !t.Overwrite && ((!t.ImagePreview || t.doesImagePreviewExist(t.Scene.Checksum)) && videoExists) { return } @@ -30,11 +32,12 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) { return } - generator, err := NewPreviewGenerator(*videoFile, videoFilename, imageFilename, instance.Paths.Generated.Screenshots, !videoExists, t.ImagePreview, t.PreviewPreset) + generator, err := NewPreviewGenerator(*videoFile, videoFilename, imageFilename, instance.Paths.Generated.Screenshots, true, t.ImagePreview, t.PreviewPreset) if err != nil { logger.Errorf("error creating preview generator: %s", err.Error()) return } + generator.Overwrite = t.Overwrite if err := generator.Generate(); err != nil { logger.Errorf("error generating preview: %s", err.Error()) diff --git a/pkg/manager/task_generate_sprite.go b/pkg/manager/task_generate_sprite.go index 274098045..00ad44d28 100644 --- a/pkg/manager/task_generate_sprite.go +++ b/pkg/manager/task_generate_sprite.go @@ -1,21 +1,23 @@ package manager import ( + "sync" + "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" - "sync" ) type GenerateSpriteTask struct { - Scene models.Scene + Scene models.Scene + Overwrite bool } func (t *GenerateSpriteTask) Start(wg *sync.WaitGroup) { defer wg.Done() - if t.doesSpriteExist(t.Scene.Checksum) { + if t.doesSpriteExist(t.Scene.Checksum) && !t.Overwrite { return } @@ -28,6 +30,7 @@ func (t *GenerateSpriteTask) Start(wg *sync.WaitGroup) { imagePath := instance.Paths.Scene.GetSpriteImageFilePath(t.Scene.Checksum) vttPath := instance.Paths.Scene.GetSpriteVttFilePath(t.Scene.Checksum) generator, err := NewSpriteGenerator(*videoFile, imagePath, vttPath, 9, 9) + generator.Overwrite = t.Overwrite if err != nil { logger.Errorf("error creating sprite generator: %s", err.Error()) return diff --git a/pkg/manager/task_transcode.go b/pkg/manager/task_transcode.go index 7db3be0e3..01468ef90 100644 --- a/pkg/manager/task_transcode.go +++ b/pkg/manager/task_transcode.go @@ -11,14 +11,15 @@ import ( ) type GenerateTranscodeTask struct { - Scene models.Scene + Scene models.Scene + Overwrite bool } func (t *GenerateTranscodeTask) Start(wg *sync.WaitGroup) { defer wg.Done() hasTranscode, _ := HasTranscode(&t.Scene) - if hasTranscode { + if !t.Overwrite && hasTranscode { return } @@ -107,7 +108,7 @@ func (t *GenerateTranscodeTask) isTranscodeNeeded() bool { } hasTranscode, _ := HasTranscode(&t.Scene) - if hasTranscode { + if !t.Overwrite && hasTranscode { return false } return true diff --git a/pkg/models/querybuilder_gallery.go b/pkg/models/querybuilder_gallery.go index cf5aa8517..c5fdf532c 100644 --- a/pkg/models/querybuilder_gallery.go +++ b/pkg/models/querybuilder_gallery.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "fmt" "path/filepath" "strconv" @@ -82,6 +83,24 @@ func (qb *GalleryQueryBuilder) Find(id int) (*Gallery, error) { return qb.queryGallery(query, args, nil) } +func (qb *GalleryQueryBuilder) FindMany(ids []int) ([]*Gallery, error) { + var galleries []*Gallery + for _, id := range ids { + gallery, err := qb.Find(id) + if err != nil { + return nil, err + } + + if gallery == nil { + return nil, fmt.Errorf("gallery with id %d not found", id) + } + + galleries = append(galleries, gallery) + } + + return galleries, nil +} + func (qb *GalleryQueryBuilder) FindByChecksum(checksum string, tx *sqlx.Tx) (*Gallery, error) { query := "SELECT * FROM galleries WHERE checksum = ? LIMIT 1" args := []interface{}{checksum} diff --git a/pkg/models/querybuilder_scene.go b/pkg/models/querybuilder_scene.go index 60ab7d91f..29bd21814 100644 --- a/pkg/models/querybuilder_scene.go +++ b/pkg/models/querybuilder_scene.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "fmt" "strings" "github.com/jmoiron/sqlx" @@ -147,6 +148,24 @@ func (qb *SceneQueryBuilder) Find(id int) (*Scene, error) { return qb.find(id, nil) } +func (qb *SceneQueryBuilder) FindMany(ids []int) ([]*Scene, error) { + var scenes []*Scene + for _, id := range ids { + scene, err := qb.Find(id) + if err != nil { + return nil, err + } + + if scene == nil { + return nil, fmt.Errorf("scene with id %d not found", id) + } + + scenes = append(scenes, scene) + } + + return scenes, nil +} + func (qb *SceneQueryBuilder) find(id int, tx *sqlx.Tx) (*Scene, error) { query := selectAll(sceneTable) + "WHERE id = ? LIMIT 1" args := []interface{}{id} diff --git a/pkg/models/querybuilder_scene_marker.go b/pkg/models/querybuilder_scene_marker.go index 8cd9f0d69..5edba444c 100644 --- a/pkg/models/querybuilder_scene_marker.go +++ b/pkg/models/querybuilder_scene_marker.go @@ -2,9 +2,11 @@ package models import ( "database/sql" + "fmt" + "strconv" + "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/database" - "strconv" ) const countSceneMarkersForTagQuery = ` @@ -72,6 +74,24 @@ func (qb *SceneMarkerQueryBuilder) Find(id int) (*SceneMarker, error) { return results[0], nil } +func (qb *SceneMarkerQueryBuilder) FindMany(ids []int) ([]*SceneMarker, error) { + var markers []*SceneMarker + for _, id := range ids { + marker, err := qb.Find(id) + if err != nil { + return nil, err + } + + if marker == nil { + return nil, fmt.Errorf("scene marker with id %d not found", id) + } + + markers = append(markers, marker) + } + + return markers, nil +} + func (qb *SceneMarkerQueryBuilder) FindBySceneID(sceneID int, tx *sqlx.Tx) ([]*SceneMarker, error) { query := ` SELECT scene_markers.* FROM scene_markers diff --git a/pkg/utils/string_collections.go b/pkg/utils/string_collections.go index 5702e9375..aff2d136a 100644 --- a/pkg/utils/string_collections.go +++ b/pkg/utils/string_collections.go @@ -1,5 +1,7 @@ package utils +import "strconv" + // https://gobyexample.com/collection-functions func StrIndex(vs []string, t string) int { @@ -32,3 +34,15 @@ func StrMap(vs []string, f func(string) string) []string { } return vsm } + +// StringSliceToIntSlice converts a slice of strings to a slice of ints. If any +// values cannot be parsed, then they are inserted into the returned slice as +// 0. +func StringSliceToIntSlice(ss []string) []int { + ret := make([]int, len(ss)) + for i, v := range ss { + ret[i], _ = strconv.Atoi(v) + } + + return ret +} diff --git a/ui/v2.5/src/components/Changelog/versions/v030.tsx b/ui/v2.5/src/components/Changelog/versions/v030.tsx index cd328e5de..75ce788e4 100644 --- a/ui/v2.5/src/components/Changelog/versions/v030.tsx +++ b/ui/v2.5/src/components/Changelog/versions/v030.tsx @@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown"; const markup = ` ### ✨ New Features +* Support (re-)generation of generated content for specific scenes. * Add tag thumbnails, tags grid view and tag page. * Add post-scrape dialog. * Add various keyboard shortcuts (see manual). diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index d234db8cd..f709aa0da 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -25,6 +25,7 @@ import { AddFilter } from "./AddFilter"; interface IListFilterOperation { text: string; onClick: () => void; + isDisplayed?: () => boolean; } interface IListFilterProps { @@ -363,17 +364,25 @@ export const ListFilter: React.FC = ( const options = [renderSelectAll(), renderSelectNone()]; if (props.otherOperations) { - props.otherOperations.forEach((o) => { - options.push( - - {o.text} - - ); - }); + props.otherOperations + .filter((o) => { + if (!o.isDisplayed) { + return true; + } + + return o.isDisplayed(); + }) + .forEach((o) => { + options.push( + + {o.text} + + ); + }); } if (options.length > 0) { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index f05141c08..0a21834c3 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -23,6 +23,7 @@ import { SceneDetailPanel } from "./SceneDetailPanel"; import { OCounterButton } from "./OCounterButton"; import { SceneMoviePanel } from "./SceneMoviePanel"; import { DeleteScenesDialog } from "../DeleteScenesDialog"; +import { SceneGenerateDialog } from "../SceneGenerateDialog"; export const Scene: React.FC = () => { const { id = "new" } = useParams(); @@ -42,6 +43,7 @@ export const Scene: React.FC = () => { const [activeTabKey, setActiveTabKey] = useState("scene-details-panel"); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); const queryParams = queryString.parse(location.search); const autoplay = queryParams?.autoplay === "true"; @@ -134,6 +136,19 @@ export const Scene: React.FC = () => { } } + function maybeRenderSceneGenerateDialog() { + if (isGenerateDialogOpen && scene) { + return ( + { + setIsGenerateDialogOpen(false); + }} + /> + ); + } + } + function renderOperations() { return ( @@ -146,6 +161,13 @@ export const Scene: React.FC = () => { + setIsGenerateDialogOpen(true)} + > + Generate... + { return (
+ {maybeRenderSceneGenerateDialog()} {maybeRenderDeleteDialog()}
diff --git a/ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx b/ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx new file mode 100644 index 000000000..486b2246b --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx @@ -0,0 +1,105 @@ +import React, { useState } from "react"; +import { Form } from "react-bootstrap"; +import { mutateMetadataGenerate } from "src/core/StashService"; +import { Modal } from "src/components/Shared"; +import { useToast } from "src/hooks"; + +interface ISceneGenerateDialogProps { + selectedIds: string[]; + onClose: () => void; +} + +export const SceneGenerateDialog: React.FC = ( + props: ISceneGenerateDialogProps +) => { + const [sprites, setSprites] = useState(true); + const [previews, setPreviews] = useState(true); + const [markers, setMarkers] = useState(true); + const [transcodes, setTranscodes] = useState(false); + const [overwrite, setOverwrite] = useState(true); + const [imagePreviews, setImagePreviews] = useState(false); + + const Toast = useToast(); + + async function onGenerate() { + try { + await mutateMetadataGenerate({ + sprites, + previews, + imagePreviews: previews && imagePreviews, + markers, + transcodes, + thumbnails: false, + overwrite, + sceneIDs: props.selectedIds, + }); + Toast.success({ content: "Started generating" }); + } catch (e) { + Toast.error(e); + } finally { + props.onClose(); + } + } + + return ( + props.onClose(), + text: "Cancel", + variant: "secondary", + }} + > +
+ + setPreviews(!previews)} + /> +
+
+ setImagePreviews(!imagePreviews)} + className="ml-2 flex-grow" + /> +
+ setSprites(!sprites)} + /> + setMarkers(!markers)} + /> + setTranscodes(!transcodes)} + /> + +
+ setOverwrite(!overwrite)} + /> +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 34d24aafb..4f544de7a 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import _ from "lodash"; import { useHistory } from "react-router-dom"; import { @@ -9,11 +9,13 @@ import { queryFindScenes } from "src/core/StashService"; import { useScenesList } from "src/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; +import { showWhenSelected } from "src/hooks/ListHook"; import { WallPanel } from "../Wall/WallPanel"; import { SceneCard } from "./SceneCard"; import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; +import { SceneGenerateDialog } from "./SceneGenerateDialog"; interface ISceneList { subComponent?: boolean; @@ -25,11 +27,18 @@ export const SceneList: React.FC = ({ filterHook, }) => { const history = useHistory(); + const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); + const otherOperations = [ { text: "Play Random", onClick: playRandom, }, + { + text: "Generate...", + onClick: generate, + isDisplayed: showWhenSelected, + }, ]; const addKeybinds = ( @@ -82,6 +91,25 @@ export const SceneList: React.FC = ({ } } + async function generate() { + setIsGenerateDialogOpen(true); + } + + function maybeRenderSceneGenerateDialog(selectedIds: Set) { + if (isGenerateDialogOpen) { + return ( + <> + { + setIsGenerateDialogOpen(false); + }} + /> + + ); + } + } + function renderEditScenesDialog( selectedScenes: SlimSceneDataFragment[], onClose: (applied: boolean) => void @@ -123,7 +151,7 @@ export const SceneList: React.FC = ({ ); } - function renderContent( + function renderScenes( result: FindScenesQueryResult, filter: ListFilterModel, selectedIds: Set, @@ -149,5 +177,19 @@ export const SceneList: React.FC = ({ } } + function renderContent( + result: FindScenesQueryResult, + filter: ListFilterModel, + selectedIds: Set, + zoomIndex: number + ) { + return ( + <> + {maybeRenderSceneGenerateDialog(selectedIds)} + {renderScenes(result, filter, selectedIds, zoomIndex)} + + ); + } + return listData.template; }; diff --git a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx index 249387202..9f7fe8e50 100644 --- a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx @@ -17,6 +17,9 @@ export const SettingsConfigurationPanel: React.FC = () => { undefined ); const [cachePath, setCachePath] = useState(undefined); + const [previewPreset, setPreviewPreset] = useState( + GQL.PreviewPreset.Slow + ); const [maxTranscodeSize, setMaxTranscodeSize] = useState< GQL.StreamingResolutionEnum | undefined >(undefined); @@ -44,6 +47,7 @@ export const SettingsConfigurationPanel: React.FC = () => { databasePath, generatedPath, cachePath, + previewPreset: (previewPreset as GQL.PreviewPreset) ?? undefined, maxTranscodeSize, maxStreamingTranscodeSize, forceMkv, @@ -68,6 +72,7 @@ export const SettingsConfigurationPanel: React.FC = () => { setDatabasePath(conf.general.databasePath); setGeneratedPath(conf.general.generatedPath); setCachePath(conf.general.cachePath); + setPreviewPreset(conf.general.previewPreset); setMaxTranscodeSize(conf.general.maxTranscodeSize ?? undefined); setMaxStreamingTranscodeSize( conf.general.maxStreamingTranscodeSize ?? undefined @@ -274,10 +279,32 @@ export const SettingsConfigurationPanel: React.FC = () => {

Video

+ +
Preview encoding preset
+ ) => + setPreviewPreset(e.currentTarget.value) + } + > + {Object.keys(GQL.PreviewPreset).map((p) => ( + + ))} + + + The preset regulates size, quality and encoding time of preview + generation. Presets beyond “slow” have diminishing returns and are + not recommended. + +
Maximum transcode size
) => setMaxTranscodeSize(translateQuality(event.currentTarget.value)) @@ -297,7 +324,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
Maximum streaming transcode size
) => setMaxStreamingTranscodeSize( diff --git a/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx b/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx index 22e5c8c66..65ceec0c4 100644 --- a/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx +++ b/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx @@ -1,7 +1,6 @@ import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { mutateMetadataGenerate } from "src/core/StashService"; -import { PreviewPreset } from "src/core/generated-graphql"; import { useToast } from "src/hooks"; export const GenerateButton: React.FC = () => { @@ -12,9 +11,6 @@ export const GenerateButton: React.FC = () => { const [transcodes, setTranscodes] = useState(false); const [thumbnails, setThumbnails] = useState(false); const [imagePreviews, setImagePreviews] = useState(false); - const [previewPreset, setPreviewPreset] = useState( - PreviewPreset.Slow - ); async function onGenerate() { try { @@ -25,7 +21,6 @@ export const GenerateButton: React.FC = () => { markers, transcodes, thumbnails, - previewPreset: (previewPreset as PreviewPreset) ?? undefined, }); Toast.success({ content: "Started generating" }); } catch (e) { @@ -53,31 +48,6 @@ export const GenerateButton: React.FC = () => { className="ml-2 flex-grow" />
- - -
Preview encoding preset
-
- ) => - setPreviewPreset(e.currentTarget.value) - } - disabled={!previews} - className="col-1" - > - {Object.keys(PreviewPreset).map((p) => ( - - ))} - - - The preset regulates size, quality and encoding time of preview - generation. Presets beyond “slow” have diminishing returns and are - not recommended. - -
{ filter: ListFilterModel, selectedIds: Set ) => void; + isDisplayed?: ( + result: T, + filter: ListFilterModel, + selectedIds: Set + ) => boolean; } interface IListHookOptions { @@ -346,6 +351,13 @@ const useList = ( onClick: () => { o.onClick(result, filter, selectedIds); }, + isDisplayed: () => { + if (o.isDisplayed) { + return o.isDisplayed(result, filter, selectedIds); + } + + return true; + }, }; }) : undefined; @@ -594,3 +606,11 @@ export const useTagsList = ( getSelectedData: (result: FindTagsQueryResult, selectedIds: Set) => getSelectedData(result?.data?.findTags?.tags ?? [], selectedIds), }); + +export const showWhenSelected = ( + result: FindScenesQueryResult, + filter: ListFilterModel, + selectedIds: Set +) => { + return selectedIds.size > 0; +};