diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index a96341653..2a56e9512 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -25,6 +25,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { maxTranscodeSize maxStreamingTranscodeSize writeImageThumbnails + createImageClipsFromVideos apiKey username password @@ -140,6 +141,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { scanGenerateSprites scanGeneratePhashes scanGenerateThumbnails + scanGenerateClipPreviews } identify { @@ -180,6 +182,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { transcodes phashes interactiveHeatmapsSpeeds + clipPreviews } deleteFile diff --git a/graphql/documents/data/file.graphql b/graphql/documents/data/file.graphql index 7acb95feb..52a4c50f8 100644 --- a/graphql/documents/data/file.graphql +++ b/graphql/documents/data/file.graphql @@ -43,4 +43,46 @@ fragment GalleryFileData on GalleryFile { type value } -} \ No newline at end of file +} + +fragment VisualFileData on VisualFile { + ... on BaseFile { + id + path + size + mod_time + fingerprints { + type + value + } + } + ... on ImageFile { + id + path + size + mod_time + width + height + fingerprints { + type + value + } + } + ... on VideoFile { + id + path + size + mod_time + duration + video_codec + audio_codec + width + height + frame_rate + bit_rate + fingerprints { + type + value + } + } +} diff --git a/graphql/documents/data/image-slim.graphql b/graphql/documents/data/image-slim.graphql index 4f787d36e..9f84904dc 100644 --- a/graphql/documents/data/image-slim.graphql +++ b/graphql/documents/data/image-slim.graphql @@ -13,6 +13,7 @@ fragment SlimImageData on Image { paths { thumbnail + preview image } @@ -45,4 +46,8 @@ fragment SlimImageData on Image { favorite image_path } + + visual_files { + ...VisualFileData + } } diff --git a/graphql/documents/data/image.graphql b/graphql/documents/data/image.graphql index f9adb5515..155c940e4 100644 --- a/graphql/documents/data/image.graphql +++ b/graphql/documents/data/image.graphql @@ -15,6 +15,7 @@ fragment ImageData on Image { paths { thumbnail + preview image } @@ -33,4 +34,8 @@ fragment ImageData on Image { performers { ...PerformerData } + + visual_files { + ...VisualFileData + } } diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 904d235dd..6c9939385 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -106,6 +106,8 @@ input ConfigGeneralInput { """Write image thumbnails to disk when generating on the fly""" writeImageThumbnails: Boolean + """Create Image Clips from Video extensions when Videos are disabled in Library""" + createImageClipsFromVideos: Boolean """Username""" username: String """Password""" @@ -215,6 +217,8 @@ type ConfigGeneralResult { """Write image thumbnails to disk when generating on the fly""" writeImageThumbnails: Boolean! + """Create Image Clips from Video extensions when Videos are disabled in Library""" + createImageClipsFromVideos: Boolean! """API Key""" apiKey: String! """Username""" diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index 09b733c39..755d63215 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -73,12 +73,14 @@ type ImageFile implements BaseFile { fingerprints: [Fingerprint!]! width: Int! - height: Int! + height: Int! created_at: Time! updated_at: Time! } +union VisualFile = VideoFile | ImageFile + type GalleryFile implements BaseFile { id: ID! path: String! diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index 6832cab24..c2e34f085 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -16,8 +16,9 @@ type Image { file_mod_time: Time @deprecated(reason: "Use files.mod_time") - file: ImageFileType! @deprecated(reason: "Use files.mod_time") - files: [ImageFile!]! + file: ImageFileType! @deprecated(reason: "Use visual_files") + files: [ImageFile!]! @deprecated(reason: "Use visual_files") + visual_files: [VisualFile!]! paths: ImagePathsType! # Resolver galleries: [Gallery!]! @@ -35,6 +36,7 @@ type ImageFileType { type ImagePathsType { thumbnail: String # Resolver + preview: String # Resolver image: String # Resolver } @@ -95,4 +97,4 @@ type FindImagesResultType { """Total file size in bytes""" filesize: Float! images: [Image!]! -} \ No newline at end of file +} diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index ecde11eac..8e575b3ec 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -14,6 +14,7 @@ input GenerateMetadataInput { forceTranscodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean + clipPreviews: Boolean """scene ids to generate for""" sceneIDs: [ID!] @@ -49,6 +50,7 @@ type GenerateMetadataOptions { transcodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean + clipPreviews: Boolean } type GeneratePreviewOptions { @@ -98,6 +100,8 @@ input ScanMetadataInput { scanGeneratePhashes: Boolean """Generate image thumbnails during scan""" scanGenerateThumbnails: Boolean + """Generate image clip previews during scan""" + scanGenerateClipPreviews: Boolean "Filter options for the scan" filter: ScanMetaDataFilterInput @@ -120,6 +124,8 @@ type ScanMetadataOptions { scanGeneratePhashes: Boolean! """Generate image thumbnails during scan""" scanGenerateThumbnails: Boolean! + """Generate image clip previews during scan""" + scanGenerateClipPreviews: Boolean! } input CleanMetadataInput { diff --git a/internal/api/resolver_model_image.go b/internal/api/resolver_model_image.go index 2a1965c4e..9bfadafc7 100644 --- a/internal/api/resolver_model_image.go +++ b/internal/api/resolver_model_image.go @@ -12,42 +12,55 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func (r *imageResolver) getPrimaryFile(ctx context.Context, obj *models.Image) (*file.ImageFile, error) { +func convertImageFile(f *file.ImageFile) *ImageFile { + ret := &ImageFile{ + ID: strconv.Itoa(int(f.ID)), + Path: f.Path, + Basename: f.Basename, + ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), + ModTime: f.ModTime, + Size: f.Size, + Width: f.Width, + Height: f.Height, + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + Fingerprints: resolveFingerprints(f.Base()), + } + + if f.ZipFileID != nil { + zipFileID := strconv.Itoa(int(*f.ZipFileID)) + ret.ZipFileID = &zipFileID + } + + return ret +} + +func (r *imageResolver) getPrimaryFile(ctx context.Context, obj *models.Image) (file.VisualFile, error) { if obj.PrimaryFileID != nil { f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) if err != nil { return nil, err } - ret, ok := f.(*file.ImageFile) + asFrame, ok := f.(file.VisualFile) if !ok { - return nil, fmt.Errorf("file %T is not an image file", f) + return nil, fmt.Errorf("file %T is not an frame", f) } - return ret, nil + return asFrame, nil } return nil, nil } -func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]*file.ImageFile, error) { +func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]file.File, error) { fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID) if err != nil { return nil, err } files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) - ret := make([]*file.ImageFile, len(files)) - for i, bf := range files { - f, ok := bf.(*file.ImageFile) - if !ok { - return nil, fmt.Errorf("file %T is not an image file", f) - } - - ret[i] = f - } - - return ret, firstError(errs) + return files, firstError(errs) } func (r *imageResolver) Title(ctx context.Context, obj *models.Image) (*string, error) { @@ -65,9 +78,9 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile return nil, nil } - width := f.Width - height := f.Height - size := f.Size + width := f.GetWidth() + height := f.GetHeight() + size := f.Base().Size return &ImageFileType{ Size: int(size), Width: width, @@ -75,6 +88,32 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile }, nil } +func convertVisualFile(f file.File) VisualFile { + switch f := f.(type) { + case *file.ImageFile: + return convertImageFile(f) + case *file.VideoFile: + return convertVideoFile(f) + default: + panic(fmt.Sprintf("unknown file type %T", f)) + } +} + +func (r *imageResolver) VisualFiles(ctx context.Context, obj *models.Image) ([]VisualFile, error) { + fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID) + if err != nil { + return nil, err + } + + files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) + ret := make([]VisualFile, len(files)) + for i, f := range files { + ret[i] = convertVisualFile(f) + } + + return ret, firstError(errs) +} + func (r *imageResolver) Date(ctx context.Context, obj *models.Image) (*string, error) { if obj.Date != nil { result := obj.Date.String() @@ -89,27 +128,18 @@ func (r *imageResolver) Files(ctx context.Context, obj *models.Image) ([]*ImageF return nil, err } - ret := make([]*ImageFile, len(files)) + var ret []*ImageFile - for i, f := range files { - ret[i] = &ImageFile{ - ID: strconv.Itoa(int(f.ID)), - Path: f.Path, - Basename: f.Basename, - ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), - ModTime: f.ModTime, - Size: f.Size, - Width: f.Width, - Height: f.Height, - CreatedAt: f.CreatedAt, - UpdatedAt: f.UpdatedAt, - Fingerprints: resolveFingerprints(f.Base()), + for _, f := range files { + // filter out non-image files + imageFile, ok := f.(*file.ImageFile) + if !ok { + continue } - if f.ZipFileID != nil { - zipFileID := strconv.Itoa(int(*f.ZipFileID)) - ret[i].ZipFileID = &zipFileID - } + thisFile := convertImageFile(imageFile) + + ret = append(ret, thisFile) } return ret, nil @@ -121,7 +151,7 @@ func (r *imageResolver) FileModTime(ctx context.Context, obj *models.Image) (*ti return nil, err } if f != nil { - return &f.ModTime, nil + return &f.Base().ModTime, nil } return nil, nil @@ -131,10 +161,12 @@ func (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*ImagePat baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewImageURLBuilder(baseURL, obj) thumbnailPath := builder.GetThumbnailURL() + previewPath := builder.GetPreviewURL() imagePath := builder.GetImageURL() return &ImagePathsType{ Image: &imagePath, Thumbnail: &thumbnailPath, + Preview: &previewPath, }, nil } diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 99f42e64f..cd6f16a57 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -14,6 +14,35 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +func convertVideoFile(f *file.VideoFile) *VideoFile { + ret := &VideoFile{ + ID: strconv.Itoa(int(f.ID)), + Path: f.Path, + Basename: f.Basename, + ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), + ModTime: f.ModTime, + Format: f.Format, + Size: f.Size, + Duration: handleFloat64Value(f.Duration), + VideoCodec: f.VideoCodec, + AudioCodec: f.AudioCodec, + Width: f.Width, + Height: f.Height, + FrameRate: handleFloat64Value(f.FrameRate), + BitRate: int(f.BitRate), + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + Fingerprints: resolveFingerprints(f.Base()), + } + + if f.ZipFileID != nil { + zipFileID := strconv.Itoa(int(*f.ZipFileID)) + ret.ZipFileID = &zipFileID + } + + return ret +} + func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (*file.VideoFile, error) { if obj.PrimaryFileID != nil { f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) @@ -112,30 +141,7 @@ func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoF ret := make([]*VideoFile, len(files)) for i, f := range files { - ret[i] = &VideoFile{ - ID: strconv.Itoa(int(f.ID)), - Path: f.Path, - Basename: f.Basename, - ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), - ModTime: f.ModTime, - Format: f.Format, - Size: f.Size, - Duration: handleFloat64Value(f.Duration), - VideoCodec: f.VideoCodec, - AudioCodec: f.AudioCodec, - Width: f.Width, - Height: f.Height, - FrameRate: handleFloat64Value(f.FrameRate), - BitRate: int(f.BitRate), - CreatedAt: f.CreatedAt, - UpdatedAt: f.UpdatedAt, - Fingerprints: resolveFingerprints(f.Base()), - } - - if f.ZipFileID != nil { - zipFileID := strconv.Itoa(int(*f.ZipFileID)) - ret[i].ZipFileID = &zipFileID - } + ret[i] = convertVideoFile(f) } return ret, nil diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 2a102af6e..bdc93137f 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -218,6 +218,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails) } + if input.CreateImageClipsFromVideos != nil { + c.Set(config.CreateImageClipsFromVideos, *input.CreateImageClipsFromVideos) + } + if input.GalleryCoverRegex != nil { _, err := regexp.Compile(*input.GalleryCoverRegex) diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 6a482ff04..353dab744 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -126,9 +126,9 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp } // ensure that new primary file is associated with scene - var f *file.ImageFile + var f file.File for _, ff := range i.Files.List() { - if ff.ID == converted { + if ff.Base().ID == converted { f = ff } } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 643aa263b..4c9f00aea 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -106,6 +106,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { MaxTranscodeSize: &maxTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, WriteImageThumbnails: config.IsWriteImageThumbnails(), + CreateImageClipsFromVideos: config.IsCreateImageClipsFromVideos(), GalleryCoverRegex: config.GetGalleryCoverRegex(), APIKey: config.GetAPIKey(), Username: config.GetUsername(), diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 2685a7a76..4ea612d3b 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -40,6 +40,7 @@ func (rs imageRoutes) Routes() chi.Router { r.Get("/image", rs.Image) r.Get("/thumbnail", rs.Thumbnail) + r.Get("/preview", rs.Preview) }) return r @@ -64,13 +65,19 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { return } - encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG) + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(), + OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(), + Preset: manager.GetInstance().Config.GetPreviewPreset().String(), + } + + encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG, manager.GetInstance().FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { // don't log for unsupported image format // don't log for file not found - can optionally be logged in serveImage if !errors.Is(err, image.ErrNotSupportedForThumbnail) && !errors.Is(err, fs.ErrNotExist) { - logger.Errorf("error generating thumbnail for %s: %v", f.Path, err) + logger.Errorf("error generating thumbnail for %s: %v", f.Base().Path, err) var exitErr *exec.ExitError if errors.As(err, &exitErr) { @@ -96,6 +103,14 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { } } +func (rs imageRoutes) Preview(w http.ResponseWriter, r *http.Request) { + img := r.Context().Value(imageKey).(*models.Image) + filepath := manager.GetInstance().Paths.Generated.GetClipPreviewPath(img.Checksum, models.DefaultGthumbWidth) + + // don't check if the preview exists - we'll just return a 404 if it doesn't + utils.ServeStaticFile(w, r, filepath) +} + func (rs imageRoutes) Image(w http.ResponseWriter, r *http.Request) { i := r.Context().Value(imageKey).(*models.Image) @@ -107,7 +122,7 @@ func (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *mode const defaultImageImage = "image/image.svg" if i.Files.Primary() != nil { - err := i.Files.Primary().Serve(&file.OsFS{}, w, r) + err := i.Files.Primary().Base().Serve(&file.OsFS{}, w, r) if err == nil { return } diff --git a/internal/api/urlbuilders/image.go b/internal/api/urlbuilders/image.go index 735ce9610..3bc77d30b 100644 --- a/internal/api/urlbuilders/image.go +++ b/internal/api/urlbuilders/image.go @@ -3,12 +3,15 @@ package urlbuilders import ( "strconv" + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" ) type ImageURLBuilder struct { BaseURL string ImageID string + Checksum string UpdatedAt string } @@ -16,6 +19,7 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder { return ImageURLBuilder{ BaseURL: baseURL, ImageID: strconv.Itoa(image.ID), + Checksum: image.Checksum, UpdatedAt: strconv.FormatInt(image.UpdatedAt.Unix(), 10), } } @@ -27,3 +31,11 @@ func (b ImageURLBuilder) GetImageURL() string { func (b ImageURLBuilder) GetThumbnailURL() string { return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt } + +func (b ImageURLBuilder) GetPreviewURL() string { + if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil { + return b.BaseURL + "/image/" + b.ImageID + "/preview?" + b.UpdatedAt + } else { + return "" + } +} diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index fe9730219..44c643925 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -96,6 +96,9 @@ const ( WriteImageThumbnails = "write_image_thumbnails" writeImageThumbnailsDefault = true + CreateImageClipsFromVideos = "create_image_clip_from_videos" + createImageClipsFromVideosDefault = false + Host = "host" hostDefault = "0.0.0.0" @@ -865,6 +868,10 @@ func (i *Instance) IsWriteImageThumbnails() bool { return i.getBool(WriteImageThumbnails) } +func (i *Instance) IsCreateImageClipsFromVideos() bool { + return i.getBool(CreateImageClipsFromVideos) +} + func (i *Instance) GetAPIKey() string { return i.getString(ApiKey) } @@ -1513,6 +1520,7 @@ func (i *Instance) setDefaultValues(write bool) error { i.main.SetDefault(ThemeColor, DefaultThemeColor) i.main.SetDefault(WriteImageThumbnails, writeImageThumbnailsDefault) + i.main.SetDefault(CreateImageClipsFromVideos, createImageClipsFromVideosDefault) i.main.SetDefault(Database, defaultDatabaseFilePath) diff --git a/internal/manager/config/tasks.go b/internal/manager/config/tasks.go index 1e541fcc5..b87a1d23a 100644 --- a/internal/manager/config/tasks.go +++ b/internal/manager/config/tasks.go @@ -19,6 +19,8 @@ type ScanMetadataOptions struct { ScanGeneratePhashes bool `json:"scanGeneratePhashes"` // Generate image thumbnails during scan ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"` + // Generate image thumbnails during scan + ScanGenerateClipPreviews bool `json:"scanGenerateClipPreviews"` } type AutoTagMetadataOptions struct { diff --git a/internal/manager/fingerprint.go b/internal/manager/fingerprint.go index 5c2c66352..fc183cc6a 100644 --- a/internal/manager/fingerprint.go +++ b/internal/manager/fingerprint.go @@ -63,7 +63,7 @@ func (c *fingerprintCalculator) CalculateFingerprints(f *file.BaseFile, o file.O var ret []file.Fingerprint calculateMD5 := true - if isVideo(f.Basename) { + if useAsVideo(f.Path) { var ( fp *file.Fingerprint err error diff --git a/internal/manager/manager.go b/internal/manager/manager.go index a952b712c..6d776fcf7 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -279,11 +279,11 @@ func initialize() error { } func videoFileFilter(ctx context.Context, f file.File) bool { - return isVideo(f.Base().Basename) + return useAsVideo(f.Base().Path) } func imageFileFilter(ctx context.Context, f file.File) bool { - return isImage(f.Base().Basename) + return useAsImage(f.Base().Path) } func galleryFileFilter(ctx context.Context, f file.File) bool { @@ -306,8 +306,10 @@ func makeScanner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Scanner { Filter: file.FilterFunc(videoFileFilter), }, &file.FilteredDecorator{ - Decorator: &file_image.Decorator{}, - Filter: file.FilterFunc(imageFileFilter), + Decorator: &file_image.Decorator{ + FFProbe: instance.FFProbe, + }, + Filter: file.FilterFunc(imageFileFilter), }, }, FingerprintCalculator: &fingerprintCalculator{instance.Config}, diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 10bcacab0..3987fb9ba 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -15,6 +15,20 @@ import ( "github.com/stashapp/stash/pkg/models" ) +func useAsVideo(pathname string) bool { + if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo { + return false + } + return isVideo(pathname) +} + +func useAsImage(pathname string) bool { + if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo { + return isImage(pathname) || isVideo(pathname) + } + return isImage(pathname) +} + func isZip(pathname string) bool { gExt := config.GetInstance().GetGalleryExtensions() return fsutil.MatchExtension(pathname, gExt) diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 41ac5f12e..dd49c4af7 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -15,7 +15,6 @@ import ( type ImageReaderWriter interface { models.ImageReaderWriter image.FinderCreatorUpdater - models.ImageFileLoader GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) } diff --git a/internal/manager/scene.go b/internal/manager/scene.go index a653cb632..39b96fec7 100644 --- a/internal/manager/scene.go +++ b/internal/manager/scene.go @@ -88,7 +88,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea // convert StreamingResolutionEnum to ResolutionEnum maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize) - sceneResolution := pf.GetMinResolution() + sceneResolution := file.GetMinResolution(pf) includeSceneStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool { var minResolution int if streamingResolution == models.StreamingResolutionEnumOriginal { diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index b90f11be8..5eb4d20a9 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -201,9 +201,9 @@ func (f *cleanFilter) shouldCleanFile(path string, info fs.FileInfo, stash *conf switch { case info.IsDir() || fsutil.MatchExtension(path, f.zipExt): return f.shouldCleanGallery(path, stash) - case fsutil.MatchExtension(path, f.vidExt): + case useAsVideo(path): return f.shouldCleanVideoFile(path, stash) - case fsutil.MatchExtension(path, f.imgExt): + case useAsImage(path): return f.shouldCleanImage(path, stash) default: logger.Infof("File extension does not match any media extensions. Marking to clean: \"%s\"", path) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index c457ddedf..ce3d71000 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -7,6 +7,7 @@ import ( "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -29,6 +30,7 @@ type GenerateMetadataInput struct { ForceTranscodes bool `json:"forceTranscodes"` Phashes bool `json:"phashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` + ClipPreviews bool `json:"clipPreviews"` // scene ids to generate for SceneIDs []string `json:"sceneIDs"` // marker ids to generate for @@ -69,6 +71,7 @@ type totalsGenerate struct { transcodes int64 phashes int64 interactiveHeatmapSpeeds int64 + clipPreviews int64 tasks int } @@ -167,6 +170,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { if j.input.InteractiveHeatmapsSpeeds { logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) } + if j.input.ClipPreviews { + logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews) + } if logMsg == "Generating" { logMsg = "Nothing selected to generate" } @@ -254,6 +260,38 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que } } + *findFilter.Page = 1 + for more := j.input.ClipPreviews; more; { + if job.IsCancelled(ctx) { + return totals + } + + images, err := image.Query(ctx, j.txnManager.Image, nil, findFilter) + if err != nil { + logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) + return totals + } + + for _, ss := range images { + if job.IsCancelled(ctx) { + return totals + } + + if err := ss.LoadFiles(ctx, j.txnManager.Image); err != nil { + logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) + return totals + } + + j.queueImageJob(g, ss, queue, &totals) + } + + if len(images) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + return totals } @@ -434,3 +472,16 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene 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, + } + + if task.required() { + totals.clipPreviews++ + totals.tasks++ + queue <- task + } +} diff --git a/internal/manager/task_generate_clip_preview.go b/internal/manager/task_generate_clip_preview.go new file mode 100644 index 000000000..b43ca7514 --- /dev/null +++ b/internal/manager/task_generate_clip_preview.go @@ -0,0 +1,68 @@ +package manager + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/file" + "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 GenerateClipPreviewTask struct { + Image models.Image + Overwrite bool +} + +func (t *GenerateClipPreviewTask) GetDescription() string { + return fmt.Sprintf("Generating Preview for image Clip %s", t.Image.Path) +} + +func (t *GenerateClipPreviewTask) Start(ctx context.Context) { + if !t.required() { + return + } + + prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth) + filePath := t.Image.Files.Primary().Base().Path + + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: GetInstance().Config.GetTranscodeInputArgs(), + OutputArgs: GetInstance().Config.GetTranscodeOutputArgs(), + Preset: GetInstance().Config.GetPreviewPreset().String(), + } + + encoder := image.NewThumbnailEncoder(GetInstance().FFMPEG, GetInstance().FFProbe, clipPreviewOptions) + data, err := encoder.GetPreview(t.Image.Files.Primary(), models.DefaultGthumbWidth) + if err != nil { + logger.Errorf("getting preview for image %s: %w", filePath, err) + return + } + + err = fsutil.WriteFile(prevPath, data) + if err != nil { + logger.Errorf("writing preview for image %s: %w", filePath, err) + return + } + +} + +func (t *GenerateClipPreviewTask) required() bool { + _, ok := t.Image.Files.Primary().(*file.VideoFile) + if !ok { + return false + } + + if t.Overwrite { + return true + } + + prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth) + if exists, _ := fsutil.FileExists(prevPath); exists { + return false + } + + return true +} diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 02ebfbc30..7c5e20156 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -141,8 +141,8 @@ func newHandlerRequiredFilter(c *config.Instance) *handlerRequiredFilter { func (f *handlerRequiredFilter) Accept(ctx context.Context, ff file.File) bool { path := ff.Base().Path - isVideoFile := fsutil.MatchExtension(path, f.vidExt) - isImageFile := fsutil.MatchExtension(path, f.imgExt) + isVideoFile := useAsVideo(path) + isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) var counter fileCounter @@ -255,8 +255,8 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) return false } - isVideoFile := fsutil.MatchExtension(path, f.vidExt) - isImageFile := fsutil.MatchExtension(path, f.imgExt) + isVideoFile := useAsVideo(path) + isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) // handle caption files @@ -289,7 +289,7 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) // shortcut: skip the directory entirely if it matches both exclusion patterns // add a trailing separator so that it correctly matches against patterns like path/.* pathExcludeTest := path + string(filepath.Separator) - if (s.ExcludeVideo || matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { + if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { logger.Debugf("Skipping directory %s as it matches video and image exclusion patterns", path) return false } @@ -306,17 +306,14 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) } type scanConfig struct { - isGenerateThumbnails bool + isGenerateThumbnails bool + isGenerateClipPreviews bool } func (c *scanConfig) GetCreateGalleriesFromFolders() bool { return instance.Config.GetCreateGalleriesFromFolders() } -func (c *scanConfig) IsGenerateThumbnails() bool { - return c.isGenerateThumbnails -} - func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler { db := instance.Database pluginCache := instance.PluginCache @@ -325,11 +322,16 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(imageFileFilter), Handler: &image.ScanHandler{ - CreatorUpdater: db.Image, - GalleryFinder: db.Gallery, - ThumbnailGenerator: &imageThumbnailGenerator{}, + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanGenerator: &imageGenerators{ + input: options, + taskQueue: taskQueue, + progress: progress, + }, ScanConfig: &scanConfig{ - isGenerateThumbnails: options.ScanGenerateThumbnails, + isGenerateThumbnails: options.ScanGenerateThumbnails, + isGenerateClipPreviews: options.ScanGenerateClipPreviews, }, PluginCache: pluginCache, Paths: instance.Paths, @@ -362,35 +364,97 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre } } -type imageThumbnailGenerator struct{} +type imageGenerators struct { + input ScanMetadataInput + taskQueue *job.TaskQueue + progress *job.Progress +} -func (g *imageThumbnailGenerator) GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error { +func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f file.File) error { + const overwrite = false + + progress := g.progress + t := g.input + path := f.Base().Path + config := instance.Config + sequentialScanning := config.GetSequentialScanning() + + 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) + } + } + + // avoid adding a task if the file isn't a video file + _, isVideo := f.(*file.VideoFile) + if isVideo && t.ScanGenerateClipPreviews { + // this is a bit of a hack: the task requires files to be loaded, but + // we don't really need to since we already have the file + ii := *i + ii.Files = models.NewRelatedFiles([]file.File{f}) + + progress.AddTotal(1) + previewsFn := func(ctx context.Context) { + taskPreview := GenerateClipPreviewTask{ + Image: ii, + Overwrite: overwrite, + } + + taskPreview.Start(ctx) + progress.Increment() + } + + if sequentialScanning { + previewsFn(ctx) + } else { + g.taskQueue.Add(fmt.Sprintf("Generating preview for %s", path), previewsFn) + } + } + + return nil +} + +func (g *imageGenerators) generateThumbnail(ctx context.Context, i *models.Image, f file.File) error { thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth) exists, _ := fsutil.FileExists(thumbPath) if exists { return nil } - if f.Height <= models.DefaultGthumbWidth && f.Width <= models.DefaultGthumbWidth { + path := f.Base().Path + + asFrame, ok := f.(file.VisualFile) + if !ok { + return fmt.Errorf("file %s does not implement Frame", path) + } + + if asFrame.GetHeight() <= models.DefaultGthumbWidth && asFrame.GetWidth() <= models.DefaultGthumbWidth { return nil } - logger.Debugf("Generating thumbnail for %s", f.Path) + logger.Debugf("Generating thumbnail for %s", path) - encoder := image.NewThumbnailEncoder(instance.FFMPEG) + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: instance.Config.GetTranscodeInputArgs(), + OutputArgs: instance.Config.GetTranscodeOutputArgs(), + Preset: instance.Config.GetPreviewPreset().String(), + } + + encoder := image.NewThumbnailEncoder(instance.FFMPEG, instance.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", f.Path, err) + 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", f.Path, err) + return fmt.Errorf("writing thumbnail for image %s: %w", path, err) } return nil diff --git a/pkg/ffmpeg/transcoder/image.go b/pkg/ffmpeg/transcoder/image.go index a476dff42..4221a9a54 100644 --- a/pkg/ffmpeg/transcoder/image.go +++ b/pkg/ffmpeg/transcoder/image.go @@ -10,6 +10,7 @@ var ErrUnsupportedFormat = errors.New("unsupported image format") type ImageThumbnailOptions struct { InputFormat ffmpeg.ImageFormat + OutputFormat ffmpeg.ImageFormat OutputPath string MaxDimensions int Quality int @@ -29,12 +30,15 @@ func ImageThumbnail(input string, options ImageThumbnailOptions) ffmpeg.Args { VideoFilter(videoFilter). VideoCodec(ffmpeg.VideoCodecMJpeg) + args = append(args, "-frames:v", "1") + if options.Quality > 0 { args = args.FixedQualityScaleVideo(options.Quality) } args = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe). - Output(options.OutputPath) + Output(options.OutputPath). + ImageFormat(options.OutputFormat) return args } diff --git a/pkg/file/frame.go b/pkg/file/frame.go new file mode 100644 index 000000000..de9f74662 --- /dev/null +++ b/pkg/file/frame.go @@ -0,0 +1,20 @@ +package file + +// VisualFile is an interface for files that have a width and height. +type VisualFile interface { + File + GetWidth() int + GetHeight() int + GetFormat() string +} + +func GetMinResolution(f VisualFile) int { + w := f.GetWidth() + h := f.GetHeight() + + if w < h { + return w + } + + return h +} diff --git a/pkg/file/image/scan.go b/pkg/file/image/scan.go index a029f5cce..afe4210e0 100644 --- a/pkg/file/image/scan.go +++ b/pkg/file/image/scan.go @@ -9,12 +9,15 @@ import ( _ "image/jpeg" _ "image/png" + "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/file/video" _ "golang.org/x/image/webp" ) // Decorator adds image specific fields to a File. type Decorator struct { + FFProbe ffmpeg.FFProbe } func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file.File, error) { @@ -25,16 +28,38 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file } defer r.Close() - c, format, err := image.DecodeConfig(r) + probe, err := d.FFProbe.NewVideoFile(base.Path) if err != nil { - return f, fmt.Errorf("decoding image file %q: %w", base.Path, err) + fmt.Printf("Warning: File %q could not be read with ffprobe: %s, assuming ImageFile", base.Path, err) + c, format, err := image.DecodeConfig(r) + if err != nil { + return f, fmt.Errorf("decoding image file %q: %w", base.Path, err) + } + return &file.ImageFile{ + BaseFile: base, + Format: format, + Width: c.Width, + Height: c.Height, + }, nil + } + + isClip := true + // This list is derived from ffmpegImageThumbnail in pkg/image/thumbnail. If one gets updated, the other should be as well + for _, item := range []string{"png", "mjpeg", "webp"} { + if item == probe.VideoCodec { + isClip = false + } + } + if isClip { + videoFileDecorator := video.Decorator{FFProbe: d.FFProbe} + return videoFileDecorator.Decorate(ctx, fs, f) } return &file.ImageFile{ BaseFile: base, - Format: format, - Width: c.Width, - Height: c.Height, + Format: probe.VideoCodec, + Width: probe.Width, + Height: probe.Height, }, nil } diff --git a/pkg/file/image_file.go b/pkg/file/image_file.go index 4e1f5690a..0de2d9b98 100644 --- a/pkg/file/image_file.go +++ b/pkg/file/image_file.go @@ -7,3 +7,15 @@ type ImageFile struct { Width int `json:"width"` Height int `json:"height"` } + +func (f ImageFile) GetWidth() int { + return f.Width +} + +func (f ImageFile) GetHeight() int { + return f.Height +} + +func (f ImageFile) GetFormat() string { + return f.Format +} diff --git a/pkg/file/video_file.go b/pkg/file/video_file.go index ec08aad87..382c81e19 100644 --- a/pkg/file/video_file.go +++ b/pkg/file/video_file.go @@ -16,13 +16,14 @@ type VideoFile struct { InteractiveSpeed *int `json:"interactive_speed"` } -func (f VideoFile) GetMinResolution() int { - w := f.Width - h := f.Height - - if w < h { - return w - } - - return h +func (f VideoFile) GetWidth() int { + return f.Width +} + +func (f VideoFile) GetHeight() int { + return f.Height +} + +func (f VideoFile) GetFormat() string { + return f.Format } diff --git a/pkg/image/delete.go b/pkg/image/delete.go index b61e77045..dba0fd587 100644 --- a/pkg/image/delete.go +++ b/pkg/image/delete.go @@ -22,13 +22,19 @@ type FileDeleter struct { // MarkGeneratedFiles marks for deletion the generated files for the provided image. func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error { + var files []string thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth) exists, _ := fsutil.FileExists(thumbPath) if exists { - return d.Files([]string{thumbPath}) + files = append(files, thumbPath) + } + prevPath := d.Paths.Generated.GetClipPreviewPath(image.Checksum, models.DefaultGthumbWidth) + exists, _ = fsutil.FileExists(prevPath) + if exists { + files = append(files, prevPath) } - return nil + return d.Files(files) } // Destroy destroys an image, optionally marking the file and generated files for deletion. @@ -87,7 +93,7 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter for _, f := range i.Files.List() { // only delete files where there is no other associated image - otherImages, err := s.Repository.FindByFileID(ctx, f.ID) + otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID) if err != nil { return err } @@ -99,7 +105,7 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter // don't delete files in zip archives const deleteFile = true - if f.ZipFileID == nil { + if f.Base().ZipFileID == nil { if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { return err } diff --git a/pkg/image/export_test.go b/pkg/image/export_test.go index 7f3393d6f..64a0ebb28 100644 --- a/pkg/image/export_test.go +++ b/pkg/image/export_test.go @@ -45,11 +45,9 @@ var ( func createFullImage(id int) models.Image { return models.Image{ ID: id, - Files: models.NewRelatedImageFiles([]*file.ImageFile{ - { - BaseFile: &file.BaseFile{ - Path: path, - }, + Files: models.NewRelatedFiles([]file.File{ + &file.BaseFile{ + Path: path, }, }), Title: title, diff --git a/pkg/image/import.go b/pkg/image/import.go index b5e54e594..6dfc0bde8 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -97,7 +97,7 @@ func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image { } func (i *Importer) populateFiles(ctx context.Context) error { - files := make([]*file.ImageFile, 0) + files := make([]file.File, 0) for _, ref := range i.Input.Files { path := ref @@ -109,11 +109,11 @@ func (i *Importer) populateFiles(ctx context.Context) error { if f == nil { return fmt.Errorf("image file '%s' not found", path) } else { - files = append(files, f.(*file.ImageFile)) + files = append(files, f) } } - i.image.Files = models.NewRelatedImageFiles(files) + i.image.Files = models.NewRelatedFiles(files) return nil } @@ -311,7 +311,7 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { var err error for _, f := range i.image.Files.List() { - existing, err = i.ReaderWriter.FindByFileID(ctx, f.ID) + existing, err = i.ReaderWriter.FindByFileID(ctx, f.Base().ID) if err != nil { return nil, err } diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 20bd609dc..55eafdd97 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -29,7 +29,7 @@ type FinderCreatorUpdater interface { UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error) AddFileID(ctx context.Context, id int, fileID file.ID) error models.GalleryIDLoader - models.ImageFileLoader + models.FileLoader } type GalleryFinderCreator interface { @@ -40,14 +40,17 @@ type GalleryFinderCreator interface { type ScanConfig interface { GetCreateGalleriesFromFolders() bool - IsGenerateThumbnails() bool +} + +type ScanGenerator interface { + Generate(ctx context.Context, i *models.Image, f file.File) error } type ScanHandler struct { CreatorUpdater FinderCreatorUpdater GalleryFinder GalleryFinderCreator - ThumbnailGenerator ThumbnailGenerator + ScanGenerator ScanGenerator ScanConfig ScanConfig @@ -60,6 +63,9 @@ func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { return errors.New("CreatorUpdater is required") } + if h.ScanGenerator == nil { + return errors.New("ScanGenerator is required") + } if h.GalleryFinder == nil { return errors.New("GalleryFinder is required") } @@ -78,10 +84,7 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File return err } - imageFile, ok := f.(*file.ImageFile) - if !ok { - return ErrNotImageFile - } + imageFile := f.Base() // try to match the file to an image existing, err := h.CreatorUpdater.FindByFileID(ctx, imageFile.ID) @@ -141,22 +144,20 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File } } - if h.ScanConfig.IsGenerateThumbnails() { - // do this after the commit so that the transaction isn't held up - txn.AddPostCommitHook(ctx, func(ctx context.Context) { - for _, s := range existing { - if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil { - // just log if cover generation fails. We can try again on rescan - logger.Errorf("Error generating thumbnail for %s: %v", imageFile.Path, err) - } + // do this after the commit so that generation doesn't hold up the transaction + txn.AddPostCommitHook(ctx, func(ctx context.Context) { + for _, s := range existing { + if err := h.ScanGenerator.Generate(ctx, s, f); err != nil { + // just log if cover generation fails. We can try again on rescan + logger.Errorf("Error generating content for %s: %v", imageFile.Path, err) } - }) - } + } + }) return nil } -func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *file.ImageFile, updateExisting bool) error { +func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *file.BaseFile, updateExisting bool) error { for _, i := range existing { if err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil { return err @@ -164,7 +165,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. found := false for _, sf := range i.Files.List() { - if sf.ID == f.Base().ID { + if sf.Base().ID == f.Base().ID { found = true break } diff --git a/pkg/image/service.go b/pkg/image/service.go index 667317735..5aacc4e59 100644 --- a/pkg/image/service.go +++ b/pkg/image/service.go @@ -15,7 +15,7 @@ type FinderByFile interface { type Repository interface { FinderByFile Destroyer - models.ImageFileLoader + models.FileLoader } type Service struct { diff --git a/pkg/image/thumbnail.go b/pkg/image/thumbnail.go index 80c2139cc..ca6fd40b9 100644 --- a/pkg/image/thumbnail.go +++ b/pkg/image/thumbnail.go @@ -12,7 +12,6 @@ import ( "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/file" - "github.com/stashapp/stash/pkg/models" ) const ffmpegImageQuality = 5 @@ -27,13 +26,17 @@ var ( ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail") ) -type ThumbnailGenerator interface { - GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error +type ThumbnailEncoder struct { + FFMpeg *ffmpeg.FFMpeg + FFProbe ffmpeg.FFProbe + ClipPreviewOptions ClipPreviewOptions + vips *vipsEncoder } -type ThumbnailEncoder struct { - ffmpeg *ffmpeg.FFMpeg - vips *vipsEncoder +type ClipPreviewOptions struct { + InputArgs []string + OutputArgs []string + Preset string } func GetVipsPath() string { @@ -43,9 +46,11 @@ func GetVipsPath() string { return vipsPath } -func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder { +func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder { ret := ThumbnailEncoder{ - ffmpeg: ffmpegEncoder, + FFMpeg: ffmpegEncoder, + FFProbe: ffProbe, + ClipPreviewOptions: clipPreviewOptions, } vipsPath := GetVipsPath() @@ -61,7 +66,7 @@ func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder { // the provided max size. It resizes based on the largest X/Y direction. // It returns nil and an error if an error occurs reading, decoding or encoding // the image, or if the image is not suitable for thumbnails. -func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte, error) { +func (e *ThumbnailEncoder) GetThumbnail(f file.File, maxSize int) ([]byte, error) { reader, err := f.Open(&file.OsFS{}) if err != nil { return nil, err @@ -75,47 +80,113 @@ func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte, data := buf.Bytes() - format := f.Format - animated := f.Format == formatGif + if imageFile, ok := f.(*file.ImageFile); ok { + format := imageFile.Format + animated := imageFile.Format == formatGif - // #2266 - if image is webp, then determine if it is animated - if format == formatWebP { - animated = isWebPAnimated(data) + // #2266 - if image is webp, then determine if it is animated + if format == formatWebP { + animated = isWebPAnimated(data) + } + + // #2266 - don't generate a thumbnail for animated images + if animated { + return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format) + } } - // #2266 - don't generate a thumbnail for animated images - if animated { - return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format) + // Videofiles can only be thumbnailed with ffmpeg + if _, ok := f.(*file.VideoFile); ok { + return e.ffmpegImageThumbnail(buf, maxSize) } // vips has issues loading files from stdin on Windows if e.vips != nil && runtime.GOOS != "windows" { return e.vips.ImageThumbnail(buf, maxSize) } else { - return e.ffmpegImageThumbnail(buf, format, maxSize) + return e.ffmpegImageThumbnail(buf, maxSize) } } -func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, format string, maxSize int) ([]byte, error) { - var ffmpegFormat ffmpeg.ImageFormat +// GetPreview returns the preview clip of the provided image clip resized to +// the provided max size. It resizes based on the largest X/Y direction. +// It returns nil and an error if an error occurs reading, decoding or encoding +// the image, or if the image is not suitable for thumbnails. +// It is hardcoded to 30 seconds maximum right now +func (e *ThumbnailEncoder) GetPreview(f file.File, maxSize int) ([]byte, error) { + reader, err := f.Open(&file.OsFS{}) + if err != nil { + return nil, err + } + defer reader.Close() - switch format { - case "jpeg": - ffmpegFormat = ffmpeg.ImageFormatJpeg - case "png": - ffmpegFormat = ffmpeg.ImageFormatPng - case "webp": - ffmpegFormat = ffmpeg.ImageFormatWebp - default: - return nil, ErrUnsupportedImageFormat + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(reader); err != nil { + return nil, err } + fileData, err := e.FFProbe.NewVideoFile(f.Base().Path) + if err != nil { + return nil, err + } + if fileData.Width <= maxSize { + maxSize = fileData.Width + } + clipDuration := fileData.VideoStreamDuration + if clipDuration > 30.0 { + clipDuration = 30.0 + } + return e.getClipPreview(buf, maxSize, clipDuration, fileData.FrameRate) +} + +func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) { args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{ - InputFormat: ffmpegFormat, + OutputFormat: ffmpeg.ImageFormatJpeg, OutputPath: "-", MaxDimensions: maxSize, Quality: ffmpegImageQuality, }) - return e.ffmpeg.GenerateOutput(context.TODO(), args, image) + return e.FFMpeg.GenerateOutput(context.TODO(), args, image) +} + +func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clipDuration float64, frameRate float64) ([]byte, error) { + var thumbFilter ffmpeg.VideoFilter + thumbFilter = thumbFilter.ScaleMaxSize(maxSize) + + var thumbArgs ffmpeg.Args + thumbArgs = thumbArgs.VideoFilter(thumbFilter) + + o := e.ClipPreviewOptions + + thumbArgs = append(thumbArgs, + "-pix_fmt", "yuv420p", + "-preset", o.Preset, + "-crf", "25", + "-threads", "4", + "-strict", "-2", + "-f", "webm", + ) + + if frameRate <= 0.01 { + thumbArgs = append(thumbArgs, "-vsync", "2") + } + + thumbOptions := transcoder.TranscodeOptions{ + OutputPath: "-", + StartTime: 0, + Duration: clipDuration, + + XError: true, + SlowSeek: false, + + VideoCodec: ffmpeg.VideoCodecVP9, + VideoArgs: thumbArgs, + + ExtraInputArgs: o.InputArgs, + ExtraOutputArgs: o.OutputArgs, + } + + args := transcoder.Transcode("-", thumbOptions) + return e.FFMpeg.GenerateOutput(context.TODO(), args, image) } diff --git a/pkg/models/generate.go b/pkg/models/generate.go index 2fc66248c..c8fa9785c 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"` + ClipPreviews bool `json:"clipPreviews"` } type GeneratePreviewOptions struct { diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 42425c455..e025ba0b1 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -2,7 +2,6 @@ package models import ( "context" - "errors" "path/filepath" "strconv" "time" @@ -24,7 +23,7 @@ type Image struct { Date *Date `json:"date"` // transient - not persisted - Files RelatedImageFiles + Files RelatedFiles PrimaryFileID *file.ID // transient - path of primary file - empty if no files Path string @@ -39,14 +38,14 @@ type Image struct { PerformerIDs RelatedIDs `json:"performer_ids"` } -func (i *Image) LoadFiles(ctx context.Context, l ImageFileLoader) error { - return i.Files.load(func() ([]*file.ImageFile, error) { +func (i *Image) LoadFiles(ctx context.Context, l FileLoader) error { + return i.Files.load(func() ([]file.File, error) { return l.GetFiles(ctx, i.ID) }) } func (i *Image) LoadPrimaryFile(ctx context.Context, l file.Finder) error { - return i.Files.loadPrimary(func() (*file.ImageFile, error) { + return i.Files.loadPrimary(func() (file.File, error) { if i.PrimaryFileID == nil { return nil, nil } @@ -56,15 +55,11 @@ func (i *Image) LoadPrimaryFile(ctx context.Context, l file.Finder) error { return nil, err } - var vf *file.ImageFile if len(f) > 0 { - var ok bool - vf, ok = f[0].(*file.ImageFile) - if !ok { - return nil, errors.New("not an image file") - } + return f[0], nil } - return vf, nil + + return nil, nil }) } diff --git a/pkg/models/paths/paths_generated.go b/pkg/models/paths/paths_generated.go index aa65ea918..d87e1eed6 100644 --- a/pkg/models/paths/paths_generated.go +++ b/pkg/models/paths/paths_generated.go @@ -78,3 +78,8 @@ func (gp *generatedPaths) GetThumbnailPath(checksum string, width int) string { fname := fmt.Sprintf("%s_%d.jpg", checksum, width) return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) } + +func (gp *generatedPaths) GetClipPreviewPath(checksum string, width int) string { + fname := fmt.Sprintf("%s_%d.webm", checksum, width) + return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) +} diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index b3afcad9e..3975bffc3 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -34,10 +34,6 @@ type VideoFileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]*file.VideoFile, error) } -type ImageFileLoader interface { - GetFiles(ctx context.Context, relatedID int) ([]*file.ImageFile, error) -} - type FileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]file.File, error) } @@ -320,89 +316,6 @@ func (r *RelatedVideoFiles) loadPrimary(fn func() (*file.VideoFile, error)) erro return nil } -type RelatedImageFiles struct { - primaryFile *file.ImageFile - files []*file.ImageFile - primaryLoaded bool -} - -func NewRelatedImageFiles(files []*file.ImageFile) RelatedImageFiles { - ret := RelatedImageFiles{ - files: files, - primaryLoaded: true, - } - - if len(files) > 0 { - ret.primaryFile = files[0] - } - - return ret -} - -// Loaded returns true if the relationship has been loaded. -func (r RelatedImageFiles) Loaded() bool { - return r.files != nil -} - -// Loaded returns true if the primary file relationship has been loaded. -func (r RelatedImageFiles) PrimaryLoaded() bool { - return r.primaryLoaded -} - -// List returns the related files. Panics if the relationship has not been loaded. -func (r RelatedImageFiles) List() []*file.ImageFile { - if !r.Loaded() { - panic("relationship has not been loaded") - } - - return r.files -} - -// Primary returns the primary file. Panics if the relationship has not been loaded. -func (r RelatedImageFiles) Primary() *file.ImageFile { - if !r.PrimaryLoaded() { - panic("relationship has not been loaded") - } - - return r.primaryFile -} - -func (r *RelatedImageFiles) load(fn func() ([]*file.ImageFile, error)) error { - if r.Loaded() { - return nil - } - - var err error - r.files, err = fn() - if err != nil { - return err - } - - if len(r.files) > 0 { - r.primaryFile = r.files[0] - } - - r.primaryLoaded = true - - return nil -} - -func (r *RelatedImageFiles) loadPrimary(fn func() (*file.ImageFile, error)) error { - if r.PrimaryLoaded() { - return nil - } - - var err error - r.primaryFile, err = fn() - if err != nil { - return err - } - - r.primaryLoaded = true - - return nil -} - type RelatedFiles struct { primaryFile file.File files []file.File diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 58ec592a9..f22cacf92 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -241,7 +241,7 @@ func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) e if updatedObject.Files.Loaded() { fileIDs := make([]file.ID, len(updatedObject.Files.List())) for i, f := range updatedObject.Files.List() { - fileIDs[i] = f.ID + fileIDs[i] = f.Base().ID } if err := imagesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil { @@ -360,7 +360,7 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo return ret, nil } -func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile, error) { +func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]file.File, error) { fileIDs, err := qb.filesRepository().get(ctx, id) if err != nil { return nil, err @@ -372,16 +372,7 @@ func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile, return nil, err } - ret := make([]*file.ImageFile, len(files)) - for i, f := range files { - var ok bool - ret[i], ok = f.(*file.ImageFile) - if !ok { - return nil, fmt.Errorf("expected file to be *file.ImageFile not %T", f) - } - } - - return ret, nil + return files, nil } func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) { diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 31f6d4876..1a0fceb29 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -97,7 +97,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ imageFile.(*file.ImageFile), }), PrimaryFileID: &imageFile.Base().ID, @@ -149,7 +149,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { var fileIDs []file.ID if tt.newObject.Files.Loaded() { for _, f := range tt.newObject.Files.List() { - fileIDs = append(fileIDs, f.ID) + fileIDs = append(fileIDs, f.Base().ID) } } s := tt.newObject @@ -444,7 +444,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ makeImageFile(imageIdx1WithGallery), }), CreatedAt: createdAt, @@ -462,7 +462,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { models.Image{ ID: imageIDs[imageIdx1WithGallery], OCounter: getOCounter(imageIdx1WithGallery), - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ makeImageFile(imageIdx1WithGallery), }), GalleryIDs: models.NewRelatedIDs([]int{}), @@ -965,7 +965,7 @@ func makeImageWithID(index int) *models.Image { ret := makeImage(index, true) ret.ID = imageIDs[index] - ret.Files = models.NewRelatedImageFiles([]*file.ImageFile{makeImageFile(index)}) + ret.Files = models.NewRelatedFiles([]file.File{makeImageFile(index)}) return ret } @@ -1868,8 +1868,11 @@ func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) { t.Errorf("Error loading primary file: %s", err.Error()) return nil } - - verifyImageResolution(t, image.Files.Primary().Height, resolution) + asFrame, ok := image.Files.Primary().(file.VisualFile) + if !ok { + t.Errorf("Error: Associated primary file of image is not of type VisualFile") + } + verifyImageResolution(t, asFrame.GetHeight(), resolution) } return nil diff --git a/scripts/test_db_generator/makeTestDB.go b/scripts/test_db_generator/makeTestDB.go index bfdb042df..a54e07a87 100644 --- a/scripts/test_db_generator/makeTestDB.go +++ b/scripts/test_db_generator/makeTestDB.go @@ -347,6 +347,10 @@ func getResolution() (int, int) { return w, h } +func getBool() { + return rand.Intn(2) == 0 +} + func getDate() time.Time { s := rand.Int63n(time.Now().Unix()) @@ -371,6 +375,7 @@ func generateImageFile(parentFolderID file.FolderID, path string) file.File { BaseFile: generateBaseFile(parentFolderID, path), Height: h, Width: w, + Clip: getBool(), } } diff --git a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx index 5eb9deae6..3a860e48b 100644 --- a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx @@ -67,8 +67,8 @@ export const GalleryViewer: React.FC = ({ galleryId }) => { images.forEach((image, index) => { let imageData = { src: image.paths.thumbnail!, - width: image.files[0].width, - height: image.files[0].height, + width: image.visual_files[0].width, + height: image.visual_files[0].height, tabIndex: index, key: image.id ?? index, loading: "lazy", diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index f9f18fa68..0004325cf 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -6,7 +6,7 @@ import AutoTagging from "src/docs/en/Manual/AutoTagging.md"; import JSONSpec from "src/docs/en/Manual/JSONSpec.md"; import Configuration from "src/docs/en/Manual/Configuration.md"; import Interface from "src/docs/en/Manual/Interface.md"; -import Galleries from "src/docs/en/Manual/Galleries.md"; +import Images from "src/docs/en/Manual/Images.md"; import Scraping from "src/docs/en/Manual/Scraping.md"; import ScraperDevelopment from "src/docs/en/Manual/ScraperDevelopment.md"; import Plugins from "src/docs/en/Manual/Plugins.md"; @@ -88,9 +88,9 @@ export const Manual: React.FC = ({ content: Browsing, }, { - key: "Galleries.md", - title: "Image Galleries", - content: Galleries, + key: "Images.md", + title: "Images and Galleries", + content: Images, }, { key: "Scraping.md", diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 50ae8bcc4..28598d417 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -30,7 +30,10 @@ export const ImageCard: React.FC = ( props: IImageCardProps ) => { const file = useMemo( - () => (props.image.files.length > 0 ? props.image.files[0] : undefined), + () => + props.image.visual_files.length > 0 + ? props.image.visual_files[0] + : undefined, [props.image] ); @@ -138,6 +141,13 @@ export const ImageCard: React.FC = ( return height > width; } + const source = + props.image.paths.preview != "" + ? props.image.paths.preview ?? "" + : props.image.paths.thumbnail ?? ""; + const video = source.includes("preview"); + const ImagePreview = video ? "video" : "img"; + return ( = ( image={ <>
- {props.image.title {props.onPreview ? (
diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index eb3d1211c..dda47e9d2 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -51,7 +51,7 @@ export const Image: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); async function onRescan() { - if (!image || !image.files.length) { + if (!image || !image.visual_files.length) { return; } @@ -181,8 +181,8 @@ export const Image: React.FC = () => { - {image.files.length > 1 && ( - + {image.visual_files.length > 1 && ( + )} @@ -260,6 +260,8 @@ export const Image: React.FC = () => { } const title = objectTitle(image); + const ImageView = + image.visual_files[0].__typename == "VideoFile" ? "video" : "img"; return (
@@ -286,8 +288,16 @@ export const Image: React.FC = () => { {renderTabs()}
- {title} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index 026c51dea..2b906c6d5 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -10,7 +10,7 @@ import TextUtils from "src/utils/text"; import { TextField, URLField } from "src/utils/field"; interface IFileInfoPanelProps { - file: GQL.ImageFileDataFragment; + file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment; primary?: boolean; ofMany?: boolean; onSetPrimaryFile?: () => void; @@ -110,17 +110,17 @@ export const ImageFileInfoPanel: React.FC = ( const [loading, setLoading] = useState(false); const [deletingFile, setDeletingFile] = useState< - GQL.ImageFileDataFragment | undefined + GQL.ImageFileDataFragment | GQL.VideoFileDataFragment | undefined >(); - if (props.image.files.length === 0) { + if (props.image.visual_files.length === 0) { return <>; } - if (props.image.files.length === 1) { + if (props.image.visual_files.length === 1) { return ( <> - + {props.image.url ? (
@@ -150,14 +150,14 @@ export const ImageFileInfoPanel: React.FC = ( } return ( - + {deletingFile && ( setDeletingFile(undefined)} selected={[deletingFile]} /> )} - {props.image.files.map((file, index) => ( + {props.image.visual_files.map((file, index) => ( diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 2b3cc8c46..2b3b359a6 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -22,6 +22,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { ImageCard } from "./ImageCard"; +import { ImageWallItem } from "./ImageWallItem"; import { EditImagesDialog } from "./EditImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog"; import "flexbin/flexbin.css"; @@ -56,9 +57,12 @@ const ImageWall: React.FC = ({ images, handleImageOpen }) => { images.forEach((image, index) => { let imageData = { - src: image.paths.thumbnail!, - width: image.files[0].width, - height: image.files[0].height, + src: + image.paths.preview != "" + ? image.paths.preview! + : image.paths.thumbnail!, + width: image.visual_files[0].width, + height: image.visual_files[0].height, tabIndex: index, key: image.id, loading: "lazy", @@ -86,6 +90,7 @@ const ImageWall: React.FC = ({ images, handleImageOpen }) => { {photos.length ? ( = ( + props: IImageWallProps +) => { + type style = Record; + var imgStyle: style = { + margin: props.margin, + display: "block", + }; + + if (props.direction === "column") { + imgStyle.position = "absolute"; + imgStyle.left = props.left; + imgStyle.top = props.top; + } + + var handleClick = function handleClick( + event: React.MouseEvent + ) { + if (props.onClick) { + props.onClick(event, { index: props.index }); + } + }; + + const video = props.photo.src.includes("preview"); + const ImagePreview = video ? "video" : "img"; + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx index b91f73f8b..d8cc0f67c 100644 --- a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx @@ -130,6 +130,14 @@ export const SettingsLibraryPanel: React.FC = () => { onChange={(v) => saveGeneral({ writeImageThumbnails: v })} /> + saveGeneral({ createImageClipsFromVideos: v })} + /> + = ({ headingID="dialogs.scene_gen.interactive_heatmap_speed" onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })} /> + setOptions({ clipPreviews: v })} + /> = ({ scanGenerateSprites, scanGeneratePhashes, scanGenerateThumbnails, + scanGenerateClipPreviews, } = options; function setOptions(input: Partial) { @@ -68,6 +69,12 @@ export const ScanOptions: React.FC = ({ headingID="config.tasks.generate_thumbnails_during_scan" onChange={(v) => setOptions({ scanGenerateThumbnails: v })} /> + setOptions({ scanGenerateClipPreviews: v })} + /> ); }; diff --git a/ui/v2.5/src/core/createClient.ts b/ui/v2.5/src/core/createClient.ts index c48fa480b..b6601a6cc 100644 --- a/ui/v2.5/src/core/createClient.ts +++ b/ui/v2.5/src/core/createClient.ts @@ -88,6 +88,10 @@ const typePolicies: TypePolicies = { }, }; +const possibleTypes = { + VisualFile: ["VideoFile", "ImageFile"], +}; + export const baseURL = document.querySelector("base")?.getAttribute("href") ?? "/"; @@ -156,7 +160,10 @@ export const createClient = () => { const link = from([errorLink, splitLink]); - const cache = new InMemoryCache({ typePolicies }); + const cache = new InMemoryCache({ + typePolicies, + possibleTypes: possibleTypes, + }); const client = new ApolloClient({ link, cache, diff --git a/ui/v2.5/src/docs/en/Manual/Galleries.md b/ui/v2.5/src/docs/en/Manual/Galleries.md deleted file mode 100644 index c31e2b1c4..000000000 --- a/ui/v2.5/src/docs/en/Manual/Galleries.md +++ /dev/null @@ -1,12 +0,0 @@ -# Galleries - -**Note:** images are now included during the scan process and are loaded independently of galleries. It is _no longer necessary_ to have images in zip files to be scanned into your library. - -Galleries are automatically created from zip files found during scanning that contain images. It is also possible to automatically create galleries from folders containing images, by selecting the "Create galleries from folders containing images" checkbox in the Configuration page. It is also possible to manually create galleries. - -For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance. - -If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected. - -Images can be added to a gallery by navigating to the gallery's page, selecting the "Add" tab, querying for and selecting the images to add, then selecting "Add to Gallery" from the `...` menu button. Likewise, images may be removed from a gallery by selecting the "Images" tab, selecting the images to remove and selecting "Remove from Gallery" from the `...` menu button. - diff --git a/ui/v2.5/src/docs/en/Manual/Images.md b/ui/v2.5/src/docs/en/Manual/Images.md new file mode 100644 index 000000000..7b384596b --- /dev/null +++ b/ui/v2.5/src/docs/en/Manual/Images.md @@ -0,0 +1,27 @@ +# Images and Galleries + +Images are the parts which make up galleries, but you can also have them be scanned independently. To declare an image part of a gallery, there are four ways: + +1. Group them in a folder together and activate the **Create galleries from folders containing images** option in the library section of your settings. The gallery will get the name of the folder. +2. Group them in a folder together and create a file in the folder called .forcegallery. The gallery will get the name of the folder. +3. Group them into a zip archive together. The gallery will get the name of the archive. +4. You can simply create a gallery in stash itself by clicking on **New** in the Galleries tab. + +You can add images to every gallery manually in the gallery detail page. Deleting can be done by selecting the according images in the same view and clicking on the minus next to the edit button. + +For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance. + +If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected. + +## Image clips/gifs + +Images can also be clips/gifs. These are meant to be short video loops. Right now they are not possible in zipfiles. To declare video files to be images, there are two ways: + +1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan Video Extensions as Image Clip** option in the library section of your settings. +2. Make sure none of the file endings used by your clips/gifs are present in the **Video Extensions** and add them to the **Image Extensions** in the library section of your settings. + +A clip/gif will be a stillframe in the wall and grid view by default. To view the loop, you can go into the Lightbox Carousel (e.g. by clicking on an image in the wall view) or the image detail page. + +If you want the loop to be used as a preview on the wall and grid view, you will have to generate them. +You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image Clip Previews** and clicking generate. This takes a while, as the files are transcoded. + diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index f7df798f9..2856306ff 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -20,6 +20,7 @@ The scan task accepts the following options: | Generate scrubber sprites | Generates sprites for the scene scrubber. | | Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. | | Generate thumbnails for images | Generates thumbnails for image files. | +| Generate previews for image clips | Generates a gif/looping video as thumbnail for image clips/gifs. | # Auto Tagging See the [Auto Tagging](/help/AutoTagging.md) page. @@ -51,6 +52,7 @@ The generate task accepts the following options: | Transcodes | MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. | | Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. | | Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. | +| Image Clip Previews | Generates a gif/looping video as thumbnail for image clips/gifs. | | Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. | ## Transcodes diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index bae92ab0c..8cadd2d54 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -425,20 +425,25 @@ export const LightboxComponent: React.FC = ({ } } - const navItems = images.map((image, i) => ( - + React.createElement(image.paths.preview != "" ? "video" : "img", { + loop: image.paths.preview != "", + autoPlay: image.paths.preview != "", + src: + image.paths.preview != "" + ? image.paths.preview ?? "" + : image.paths.thumbnail ?? "", + alt: "", + className: cx(CLASSNAME_NAVIMAGE, { [CLASSNAME_NAVSELECTED]: i === index, - })} - onClick={(e: React.MouseEvent) => selectIndex(e, i)} - role="presentation" - loading="lazy" - key={image.paths.thumbnail} - onLoad={imageLoaded} - /> - )); + }), + onClick: (e: React.MouseEvent) => selectIndex(e, i), + role: "presentation", + loading: "lazy", + key: image.paths.thumbnail, + onLoad: imageLoaded, + }) + ); const onDelayChange = (e: React.ChangeEvent) => { let numberValue = Number.parseInt(e.currentTarget.value, 10); @@ -845,6 +850,7 @@ export const LightboxComponent: React.FC = ({ scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} setZoom={(v) => setZoom(v)} resetPosition={resetPosition} + isVideo={image.visual_files?.[0]?.__typename == "VideoFile"} /> ) : undefined}
diff --git a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx index dcddcbe5d..425a3aacd 100644 --- a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx +++ b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx @@ -59,6 +59,7 @@ interface IProps { setZoom: (v: number) => void; onLeft: () => void; onRight: () => void; + isVideo: boolean; } export const LightboxImage: React.FC = ({ @@ -74,6 +75,7 @@ export const LightboxImage: React.FC = ({ current, setZoom, resetPosition, + isVideo, }) => { const [defaultZoom, setDefaultZoom] = useState(1); const [moving, setMoving] = useState(false); @@ -89,7 +91,7 @@ export const LightboxImage: React.FC = ({ const container = React.createRef(); const startPoints = useRef([0, 0]); - const pointerCache = useRef[]>([]); + const pointerCache = useRef([]); const prevDiff = useRef(); const scrollAttempts = useRef(0); @@ -100,6 +102,24 @@ export const LightboxImage: React.FC = ({ setBoxWidth(box.offsetWidth); setBoxHeight(box.offsetHeight); } + + function toggleVideoPlay() { + if (container.current) { + let openVideo = container.current.getElementsByTagName("video"); + if (openVideo.length > 0) { + let rect = openVideo[0].getBoundingClientRect(); + if (Math.abs(rect.x) < document.body.clientWidth / 2) { + openVideo[0].play(); + } else { + openVideo[0].pause(); + } + } + } + } + + setTimeout(() => { + toggleVideoPlay(); + }, 250); }, [container]); useEffect(() => { @@ -233,7 +253,12 @@ export const LightboxImage: React.FC = ({ calculateInitialPosition, ]); - function getScrollMode(ev: React.WheelEvent) { + function getScrollMode( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { if (ev.shiftKey) { switch (scrollMode) { case GQL.ImageLightboxScrollMode.Zoom: @@ -246,14 +271,24 @@ export const LightboxImage: React.FC = ({ return scrollMode; } - function onContainerScroll(ev: React.WheelEvent) { + function onContainerScroll( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { // don't zoom if mouse isn't over image if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) { onImageScroll(ev); } } - function onImageScrollPanY(ev: React.WheelEvent) { + function onImageScrollPanY( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { if (current) { const [minY, maxY] = minMaxY(zoom * defaultZoom); @@ -298,7 +333,12 @@ export const LightboxImage: React.FC = ({ } } - function onImageScroll(ev: React.WheelEvent) { + function onImageScroll( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; switch (getScrollMode(ev)) { @@ -311,7 +351,11 @@ export const LightboxImage: React.FC = ({ } } - function onImageMouseOver(ev: React.MouseEvent) { + function onImageMouseOver( + ev: + | React.MouseEvent + | React.MouseEvent + ) { if (!moving) return; if (!ev.buttons) { @@ -327,14 +371,22 @@ export const LightboxImage: React.FC = ({ setPositionY(positionY + posY); } - function onImageMouseDown(ev: React.MouseEvent) { + function onImageMouseDown( + ev: + | React.MouseEvent + | React.MouseEvent + ) { startPoints.current = [ev.pageX, ev.pageY]; setMoving(true); mouseDownEvent.current = ev.nativeEvent; } - function onImageMouseUp(ev: React.MouseEvent) { + function onImageMouseUp( + ev: + | React.MouseEvent + | React.MouseEvent + ) { if (ev.button !== 0) return; if ( @@ -360,7 +412,12 @@ export const LightboxImage: React.FC = ({ } } - function onTouchStart(ev: React.TouchEvent) { + function onTouchStart( + ev: + | React.TouchEvent + | React.TouchEvent + | React.TouchEvent + ) { ev.preventDefault(); if (ev.touches.length === 1) { startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; @@ -368,7 +425,12 @@ export const LightboxImage: React.FC = ({ } } - function onTouchMove(ev: React.TouchEvent) { + function onTouchMove( + ev: + | React.TouchEvent + | React.TouchEvent + | React.TouchEvent + ) { if (!moving) return; if (ev.touches.length === 1) { @@ -381,7 +443,12 @@ export const LightboxImage: React.FC = ({ } } - function onPointerDown(ev: React.PointerEvent) { + function onPointerDown( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { // replace pointer event with the same id, if applicable pointerCache.current = pointerCache.current.filter( (e) => e.pointerId !== ev.pointerId @@ -391,7 +458,12 @@ export const LightboxImage: React.FC = ({ prevDiff.current = undefined; } - function onPointerUp(ev: React.PointerEvent) { + function onPointerUp( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { for (let i = 0; i < pointerCache.current.length; i++) { if (pointerCache.current[i].pointerId === ev.pointerId) { pointerCache.current.splice(i, 1); @@ -400,7 +472,12 @@ export const LightboxImage: React.FC = ({ } } - function onPointerMove(ev: React.PointerEvent) { + function onPointerMove( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { // find the event in the cache const cachedIndex = pointerCache.current.findIndex( (c) => c.pointerId === ev.pointerId @@ -432,6 +509,17 @@ export const LightboxImage: React.FC = ({ } } + const ImageView = isVideo ? "video" : "img"; + const customStyle = isVideo + ? { + touchAction: "none", + display: "flex", + margin: "auto", + width: "100%", + "max-height": "90vh", + } + : { touchAction: "none" }; + return (
= ({ > {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} - onImageScroll(e) : undefined} onMouseDown={(e) => onImageMouseDown(e)} onMouseUp={(e) => onImageMouseUp(e)} diff --git a/ui/v2.5/src/hooks/Lightbox/types.ts b/ui/v2.5/src/hooks/Lightbox/types.ts index 6b60422fd..f955a060a 100644 --- a/ui/v2.5/src/hooks/Lightbox/types.ts +++ b/ui/v2.5/src/hooks/Lightbox/types.ts @@ -3,6 +3,13 @@ import * as GQL from "src/core/generated-graphql"; interface IImagePaths { image?: GQL.Maybe; thumbnail?: GQL.Maybe; + preview?: GQL.Maybe; +} + +interface IFiles { + __typename?: string; + width: number; + height: number; } export interface ILightboxImage { @@ -11,6 +18,7 @@ export interface ILightboxImage { rating100?: GQL.Maybe; o_counter?: GQL.Maybe; paths: IImagePaths; + visual_files?: GQL.Maybe[]; } export interface IChapter { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 7226bd4ba..8827d38bc 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -422,6 +422,7 @@ "generating_from_paths": "Generating for scenes from the following paths", "generating_scenes": "Generating for {num} {scene}" }, + "generate_clip_previews_during_scan": "Generate previews for image clips", "generate_desc": "Generate supporting image, sprite, video, vtt and other files.", "generate_phashes_during_scan": "Generate perceptual hashes", "generate_phashes_during_scan_tooltip": "For deduplication and scene identification.", @@ -592,6 +593,10 @@ "write_image_thumbnails": { "description": "Write image thumbnails to disk when generated on-the-fly", "heading": "Write image thumbnails" + }, + "create_image_clips_from_videos": { + "description": "When a library has Videos disabled, Video Files (files ending with Video Extension) will be scanned as Image Clip", + "heading": "Scan Video Extensions as Image Clip" } } }, @@ -799,6 +804,7 @@ "destination": "Reassign to" }, "scene_gen": { + "clip_previews": "Image Clip Previews", "covers": "Scene covers", "force_transcodes": "Force Transcode generation", "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.",