Support image clips/gifs (#3583)

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
yoshnopa
2023-05-17 01:30:51 +02:00
committed by GitHub
parent 0e199a525f
commit a2e477e1a7
62 changed files with 999 additions and 363 deletions

View File

@@ -25,6 +25,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
maxTranscodeSize maxTranscodeSize
maxStreamingTranscodeSize maxStreamingTranscodeSize
writeImageThumbnails writeImageThumbnails
createImageClipsFromVideos
apiKey apiKey
username username
password password
@@ -140,6 +141,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {
scanGenerateSprites scanGenerateSprites
scanGeneratePhashes scanGeneratePhashes
scanGenerateThumbnails scanGenerateThumbnails
scanGenerateClipPreviews
} }
identify { identify {
@@ -180,6 +182,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {
transcodes transcodes
phashes phashes
interactiveHeatmapsSpeeds interactiveHeatmapsSpeeds
clipPreviews
} }
deleteFile deleteFile

View File

@@ -44,3 +44,45 @@ fragment GalleryFileData on GalleryFile {
value value
} }
} }
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
}
}
}

View File

@@ -13,6 +13,7 @@ fragment SlimImageData on Image {
paths { paths {
thumbnail thumbnail
preview
image image
} }
@@ -45,4 +46,8 @@ fragment SlimImageData on Image {
favorite favorite
image_path image_path
} }
visual_files {
...VisualFileData
}
} }

View File

@@ -15,6 +15,7 @@ fragment ImageData on Image {
paths { paths {
thumbnail thumbnail
preview
image image
} }
@@ -33,4 +34,8 @@ fragment ImageData on Image {
performers { performers {
...PerformerData ...PerformerData
} }
visual_files {
...VisualFileData
}
} }

View File

@@ -106,6 +106,8 @@ input ConfigGeneralInput {
"""Write image thumbnails to disk when generating on the fly""" """Write image thumbnails to disk when generating on the fly"""
writeImageThumbnails: Boolean writeImageThumbnails: Boolean
"""Create Image Clips from Video extensions when Videos are disabled in Library"""
createImageClipsFromVideos: Boolean
"""Username""" """Username"""
username: String username: String
"""Password""" """Password"""
@@ -215,6 +217,8 @@ type ConfigGeneralResult {
"""Write image thumbnails to disk when generating on the fly""" """Write image thumbnails to disk when generating on the fly"""
writeImageThumbnails: Boolean! writeImageThumbnails: Boolean!
"""Create Image Clips from Video extensions when Videos are disabled in Library"""
createImageClipsFromVideos: Boolean!
"""API Key""" """API Key"""
apiKey: String! apiKey: String!
"""Username""" """Username"""

View File

@@ -79,6 +79,8 @@ type ImageFile implements BaseFile {
updated_at: Time! updated_at: Time!
} }
union VisualFile = VideoFile | ImageFile
type GalleryFile implements BaseFile { type GalleryFile implements BaseFile {
id: ID! id: ID!
path: String! path: String!

View File

@@ -16,8 +16,9 @@ type Image {
file_mod_time: Time @deprecated(reason: "Use files.mod_time") file_mod_time: Time @deprecated(reason: "Use files.mod_time")
file: ImageFileType! @deprecated(reason: "Use files.mod_time") file: ImageFileType! @deprecated(reason: "Use visual_files")
files: [ImageFile!]! files: [ImageFile!]! @deprecated(reason: "Use visual_files")
visual_files: [VisualFile!]!
paths: ImagePathsType! # Resolver paths: ImagePathsType! # Resolver
galleries: [Gallery!]! galleries: [Gallery!]!
@@ -35,6 +36,7 @@ type ImageFileType {
type ImagePathsType { type ImagePathsType {
thumbnail: String # Resolver thumbnail: String # Resolver
preview: String # Resolver
image: String # Resolver image: String # Resolver
} }

View File

@@ -14,6 +14,7 @@ input GenerateMetadataInput {
forceTranscodes: Boolean forceTranscodes: Boolean
phashes: Boolean phashes: Boolean
interactiveHeatmapsSpeeds: Boolean interactiveHeatmapsSpeeds: Boolean
clipPreviews: Boolean
"""scene ids to generate for""" """scene ids to generate for"""
sceneIDs: [ID!] sceneIDs: [ID!]
@@ -49,6 +50,7 @@ type GenerateMetadataOptions {
transcodes: Boolean transcodes: Boolean
phashes: Boolean phashes: Boolean
interactiveHeatmapsSpeeds: Boolean interactiveHeatmapsSpeeds: Boolean
clipPreviews: Boolean
} }
type GeneratePreviewOptions { type GeneratePreviewOptions {
@@ -98,6 +100,8 @@ input ScanMetadataInput {
scanGeneratePhashes: Boolean scanGeneratePhashes: Boolean
"""Generate image thumbnails during scan""" """Generate image thumbnails during scan"""
scanGenerateThumbnails: Boolean scanGenerateThumbnails: Boolean
"""Generate image clip previews during scan"""
scanGenerateClipPreviews: Boolean
"Filter options for the scan" "Filter options for the scan"
filter: ScanMetaDataFilterInput filter: ScanMetaDataFilterInput
@@ -120,6 +124,8 @@ type ScanMetadataOptions {
scanGeneratePhashes: Boolean! scanGeneratePhashes: Boolean!
"""Generate image thumbnails during scan""" """Generate image thumbnails during scan"""
scanGenerateThumbnails: Boolean! scanGenerateThumbnails: Boolean!
"""Generate image clip previews during scan"""
scanGenerateClipPreviews: Boolean!
} }
input CleanMetadataInput { input CleanMetadataInput {

View File

@@ -12,42 +12,55 @@ import (
"github.com/stashapp/stash/pkg/models" "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 { if obj.PrimaryFileID != nil {
f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
ret, ok := f.(*file.ImageFile) asFrame, ok := f.(file.VisualFile)
if !ok { 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 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) fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs)
ret := make([]*file.ImageFile, len(files)) return files, firstError(errs)
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)
} }
func (r *imageResolver) Title(ctx context.Context, obj *models.Image) (*string, error) { 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 return nil, nil
} }
width := f.Width width := f.GetWidth()
height := f.Height height := f.GetHeight()
size := f.Size size := f.Base().Size
return &ImageFileType{ return &ImageFileType{
Size: int(size), Size: int(size),
Width: width, Width: width,
@@ -75,6 +88,32 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile
}, nil }, 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) { func (r *imageResolver) Date(ctx context.Context, obj *models.Image) (*string, error) {
if obj.Date != nil { if obj.Date != nil {
result := obj.Date.String() result := obj.Date.String()
@@ -89,27 +128,18 @@ func (r *imageResolver) Files(ctx context.Context, obj *models.Image) ([]*ImageF
return nil, err return nil, err
} }
ret := make([]*ImageFile, len(files)) var ret []*ImageFile
for i, f := range files { for _, f := range files {
ret[i] = &ImageFile{ // filter out non-image files
ID: strconv.Itoa(int(f.ID)), imageFile, ok := f.(*file.ImageFile)
Path: f.Path, if !ok {
Basename: f.Basename, continue
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 { thisFile := convertImageFile(imageFile)
zipFileID := strconv.Itoa(int(*f.ZipFileID))
ret[i].ZipFileID = &zipFileID ret = append(ret, thisFile)
}
} }
return ret, nil return ret, nil
@@ -121,7 +151,7 @@ func (r *imageResolver) FileModTime(ctx context.Context, obj *models.Image) (*ti
return nil, err return nil, err
} }
if f != nil { if f != nil {
return &f.ModTime, nil return &f.Base().ModTime, nil
} }
return nil, nil return nil, nil
@@ -131,10 +161,12 @@ func (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*ImagePat
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewImageURLBuilder(baseURL, obj) builder := urlbuilders.NewImageURLBuilder(baseURL, obj)
thumbnailPath := builder.GetThumbnailURL() thumbnailPath := builder.GetThumbnailURL()
previewPath := builder.GetPreviewURL()
imagePath := builder.GetImageURL() imagePath := builder.GetImageURL()
return &ImagePathsType{ return &ImagePathsType{
Image: &imagePath, Image: &imagePath,
Thumbnail: &thumbnailPath, Thumbnail: &thumbnailPath,
Preview: &previewPath,
}, nil }, nil
} }

View File

@@ -14,6 +14,35 @@ import (
"github.com/stashapp/stash/pkg/utils" "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) { func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (*file.VideoFile, error) {
if obj.PrimaryFileID != nil { if obj.PrimaryFileID != nil {
f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) 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)) ret := make([]*VideoFile, len(files))
for i, f := range files { for i, f := range files {
ret[i] = &VideoFile{ ret[i] = convertVideoFile(f)
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
}
} }
return ret, nil return ret, nil

View File

@@ -218,6 +218,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails) c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails)
} }
if input.CreateImageClipsFromVideos != nil {
c.Set(config.CreateImageClipsFromVideos, *input.CreateImageClipsFromVideos)
}
if input.GalleryCoverRegex != nil { if input.GalleryCoverRegex != nil {
_, err := regexp.Compile(*input.GalleryCoverRegex) _, err := regexp.Compile(*input.GalleryCoverRegex)

View File

@@ -126,9 +126,9 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
} }
// ensure that new primary file is associated with scene // ensure that new primary file is associated with scene
var f *file.ImageFile var f file.File
for _, ff := range i.Files.List() { for _, ff := range i.Files.List() {
if ff.ID == converted { if ff.Base().ID == converted {
f = ff f = ff
} }
} }

View File

@@ -106,6 +106,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
MaxTranscodeSize: &maxTranscodeSize, MaxTranscodeSize: &maxTranscodeSize,
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
WriteImageThumbnails: config.IsWriteImageThumbnails(), WriteImageThumbnails: config.IsWriteImageThumbnails(),
CreateImageClipsFromVideos: config.IsCreateImageClipsFromVideos(),
GalleryCoverRegex: config.GetGalleryCoverRegex(), GalleryCoverRegex: config.GetGalleryCoverRegex(),
APIKey: config.GetAPIKey(), APIKey: config.GetAPIKey(),
Username: config.GetUsername(), Username: config.GetUsername(),

View File

@@ -40,6 +40,7 @@ func (rs imageRoutes) Routes() chi.Router {
r.Get("/image", rs.Image) r.Get("/image", rs.Image)
r.Get("/thumbnail", rs.Thumbnail) r.Get("/thumbnail", rs.Thumbnail)
r.Get("/preview", rs.Preview)
}) })
return r return r
@@ -64,13 +65,19 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
return 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) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)
if err != nil { if err != nil {
// don't log for unsupported image format // don't log for unsupported image format
// don't log for file not found - can optionally be logged in serveImage // don't log for file not found - can optionally be logged in serveImage
if !errors.Is(err, image.ErrNotSupportedForThumbnail) && !errors.Is(err, fs.ErrNotExist) { 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 var exitErr *exec.ExitError
if errors.As(err, &exitErr) { 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) { func (rs imageRoutes) Image(w http.ResponseWriter, r *http.Request) {
i := r.Context().Value(imageKey).(*models.Image) 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" const defaultImageImage = "image/image.svg"
if i.Files.Primary() != nil { 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 { if err == nil {
return return
} }

View File

@@ -3,12 +3,15 @@ package urlbuilders
import ( import (
"strconv" "strconv"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
type ImageURLBuilder struct { type ImageURLBuilder struct {
BaseURL string BaseURL string
ImageID string ImageID string
Checksum string
UpdatedAt string UpdatedAt string
} }
@@ -16,6 +19,7 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder {
return ImageURLBuilder{ return ImageURLBuilder{
BaseURL: baseURL, BaseURL: baseURL,
ImageID: strconv.Itoa(image.ID), ImageID: strconv.Itoa(image.ID),
Checksum: image.Checksum,
UpdatedAt: strconv.FormatInt(image.UpdatedAt.Unix(), 10), UpdatedAt: strconv.FormatInt(image.UpdatedAt.Unix(), 10),
} }
} }
@@ -27,3 +31,11 @@ func (b ImageURLBuilder) GetImageURL() string {
func (b ImageURLBuilder) GetThumbnailURL() string { func (b ImageURLBuilder) GetThumbnailURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt 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 ""
}
}

View File

@@ -96,6 +96,9 @@ const (
WriteImageThumbnails = "write_image_thumbnails" WriteImageThumbnails = "write_image_thumbnails"
writeImageThumbnailsDefault = true writeImageThumbnailsDefault = true
CreateImageClipsFromVideos = "create_image_clip_from_videos"
createImageClipsFromVideosDefault = false
Host = "host" Host = "host"
hostDefault = "0.0.0.0" hostDefault = "0.0.0.0"
@@ -865,6 +868,10 @@ func (i *Instance) IsWriteImageThumbnails() bool {
return i.getBool(WriteImageThumbnails) return i.getBool(WriteImageThumbnails)
} }
func (i *Instance) IsCreateImageClipsFromVideos() bool {
return i.getBool(CreateImageClipsFromVideos)
}
func (i *Instance) GetAPIKey() string { func (i *Instance) GetAPIKey() string {
return i.getString(ApiKey) return i.getString(ApiKey)
} }
@@ -1513,6 +1520,7 @@ func (i *Instance) setDefaultValues(write bool) error {
i.main.SetDefault(ThemeColor, DefaultThemeColor) i.main.SetDefault(ThemeColor, DefaultThemeColor)
i.main.SetDefault(WriteImageThumbnails, writeImageThumbnailsDefault) i.main.SetDefault(WriteImageThumbnails, writeImageThumbnailsDefault)
i.main.SetDefault(CreateImageClipsFromVideos, createImageClipsFromVideosDefault)
i.main.SetDefault(Database, defaultDatabaseFilePath) i.main.SetDefault(Database, defaultDatabaseFilePath)

View File

@@ -19,6 +19,8 @@ type ScanMetadataOptions struct {
ScanGeneratePhashes bool `json:"scanGeneratePhashes"` ScanGeneratePhashes bool `json:"scanGeneratePhashes"`
// Generate image thumbnails during scan // Generate image thumbnails during scan
ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"` ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"`
// Generate image thumbnails during scan
ScanGenerateClipPreviews bool `json:"scanGenerateClipPreviews"`
} }
type AutoTagMetadataOptions struct { type AutoTagMetadataOptions struct {

View File

@@ -63,7 +63,7 @@ func (c *fingerprintCalculator) CalculateFingerprints(f *file.BaseFile, o file.O
var ret []file.Fingerprint var ret []file.Fingerprint
calculateMD5 := true calculateMD5 := true
if isVideo(f.Basename) { if useAsVideo(f.Path) {
var ( var (
fp *file.Fingerprint fp *file.Fingerprint
err error err error

View File

@@ -279,11 +279,11 @@ func initialize() error {
} }
func videoFileFilter(ctx context.Context, f file.File) bool { 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 { 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 { func galleryFileFilter(ctx context.Context, f file.File) bool {
@@ -306,7 +306,9 @@ func makeScanner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Scanner {
Filter: file.FilterFunc(videoFileFilter), Filter: file.FilterFunc(videoFileFilter),
}, },
&file.FilteredDecorator{ &file.FilteredDecorator{
Decorator: &file_image.Decorator{}, Decorator: &file_image.Decorator{
FFProbe: instance.FFProbe,
},
Filter: file.FilterFunc(imageFileFilter), Filter: file.FilterFunc(imageFileFilter),
}, },
}, },

View File

@@ -15,6 +15,20 @@ import (
"github.com/stashapp/stash/pkg/models" "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 { func isZip(pathname string) bool {
gExt := config.GetInstance().GetGalleryExtensions() gExt := config.GetInstance().GetGalleryExtensions()
return fsutil.MatchExtension(pathname, gExt) return fsutil.MatchExtension(pathname, gExt)

View File

@@ -15,7 +15,6 @@ import (
type ImageReaderWriter interface { type ImageReaderWriter interface {
models.ImageReaderWriter models.ImageReaderWriter
image.FinderCreatorUpdater image.FinderCreatorUpdater
models.ImageFileLoader
GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error)
} }

View File

@@ -88,7 +88,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea
// convert StreamingResolutionEnum to ResolutionEnum // convert StreamingResolutionEnum to ResolutionEnum
maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize) maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize)
sceneResolution := pf.GetMinResolution() sceneResolution := file.GetMinResolution(pf)
includeSceneStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool { includeSceneStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool {
var minResolution int var minResolution int
if streamingResolution == models.StreamingResolutionEnumOriginal { if streamingResolution == models.StreamingResolutionEnumOriginal {

View File

@@ -201,9 +201,9 @@ func (f *cleanFilter) shouldCleanFile(path string, info fs.FileInfo, stash *conf
switch { switch {
case info.IsDir() || fsutil.MatchExtension(path, f.zipExt): case info.IsDir() || fsutil.MatchExtension(path, f.zipExt):
return f.shouldCleanGallery(path, stash) return f.shouldCleanGallery(path, stash)
case fsutil.MatchExtension(path, f.vidExt): case useAsVideo(path):
return f.shouldCleanVideoFile(path, stash) return f.shouldCleanVideoFile(path, stash)
case fsutil.MatchExtension(path, f.imgExt): case useAsImage(path):
return f.shouldCleanImage(path, stash) return f.shouldCleanImage(path, stash)
default: default:
logger.Infof("File extension does not match any media extensions. Marking to clean: \"%s\"", path) logger.Infof("File extension does not match any media extensions. Marking to clean: \"%s\"", path)

View File

@@ -7,6 +7,7 @@ import (
"github.com/remeh/sizedwaitgroup" "github.com/remeh/sizedwaitgroup"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@@ -29,6 +30,7 @@ type GenerateMetadataInput struct {
ForceTranscodes bool `json:"forceTranscodes"` ForceTranscodes bool `json:"forceTranscodes"`
Phashes bool `json:"phashes"` Phashes bool `json:"phashes"`
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
ClipPreviews bool `json:"clipPreviews"`
// scene ids to generate for // scene ids to generate for
SceneIDs []string `json:"sceneIDs"` SceneIDs []string `json:"sceneIDs"`
// marker ids to generate for // marker ids to generate for
@@ -69,6 +71,7 @@ type totalsGenerate struct {
transcodes int64 transcodes int64
phashes int64 phashes int64
interactiveHeatmapSpeeds int64 interactiveHeatmapSpeeds int64
clipPreviews int64
tasks int tasks int
} }
@@ -167,6 +170,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
if j.input.InteractiveHeatmapsSpeeds { if j.input.InteractiveHeatmapsSpeeds {
logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds)
} }
if j.input.ClipPreviews {
logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews)
}
if logMsg == "Generating" { if logMsg == "Generating" {
logMsg = "Nothing selected to generate" 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 return totals
} }
@@ -434,3 +472,16 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene
totals.tasks++ totals.tasks++
queue <- task 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
}
}

View File

@@ -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
}

View File

@@ -141,8 +141,8 @@ func newHandlerRequiredFilter(c *config.Instance) *handlerRequiredFilter {
func (f *handlerRequiredFilter) Accept(ctx context.Context, ff file.File) bool { func (f *handlerRequiredFilter) Accept(ctx context.Context, ff file.File) bool {
path := ff.Base().Path path := ff.Base().Path
isVideoFile := fsutil.MatchExtension(path, f.vidExt) isVideoFile := useAsVideo(path)
isImageFile := fsutil.MatchExtension(path, f.imgExt) isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt) isZipFile := fsutil.MatchExtension(path, f.zipExt)
var counter fileCounter var counter fileCounter
@@ -255,8 +255,8 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
return false return false
} }
isVideoFile := fsutil.MatchExtension(path, f.vidExt) isVideoFile := useAsVideo(path)
isImageFile := fsutil.MatchExtension(path, f.imgExt) isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt) isZipFile := fsutil.MatchExtension(path, f.zipExt)
// handle caption files // 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 // shortcut: skip the directory entirely if it matches both exclusion patterns
// add a trailing separator so that it correctly matches against patterns like path/.* // add a trailing separator so that it correctly matches against patterns like path/.*
pathExcludeTest := path + string(filepath.Separator) 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) logger.Debugf("Skipping directory %s as it matches video and image exclusion patterns", path)
return false return false
} }
@@ -307,16 +307,13 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
type scanConfig struct { type scanConfig struct {
isGenerateThumbnails bool isGenerateThumbnails bool
isGenerateClipPreviews bool
} }
func (c *scanConfig) GetCreateGalleriesFromFolders() bool { func (c *scanConfig) GetCreateGalleriesFromFolders() bool {
return instance.Config.GetCreateGalleriesFromFolders() return instance.Config.GetCreateGalleriesFromFolders()
} }
func (c *scanConfig) IsGenerateThumbnails() bool {
return c.isGenerateThumbnails
}
func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler { func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler {
db := instance.Database db := instance.Database
pluginCache := instance.PluginCache pluginCache := instance.PluginCache
@@ -327,9 +324,14 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
Handler: &image.ScanHandler{ Handler: &image.ScanHandler{
CreatorUpdater: db.Image, CreatorUpdater: db.Image,
GalleryFinder: db.Gallery, GalleryFinder: db.Gallery,
ThumbnailGenerator: &imageThumbnailGenerator{}, ScanGenerator: &imageGenerators{
input: options,
taskQueue: taskQueue,
progress: progress,
},
ScanConfig: &scanConfig{ ScanConfig: &scanConfig{
isGenerateThumbnails: options.ScanGenerateThumbnails, isGenerateThumbnails: options.ScanGenerateThumbnails,
isGenerateClipPreviews: options.ScanGenerateClipPreviews,
}, },
PluginCache: pluginCache, PluginCache: pluginCache,
Paths: instance.Paths, 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) thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth)
exists, _ := fsutil.FileExists(thumbPath) exists, _ := fsutil.FileExists(thumbPath)
if exists { if exists {
return nil 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 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) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)
if err != nil { if err != nil {
// don't log for animated images // don't log for animated images
if !errors.Is(err, image.ErrNotSupportedForThumbnail) { 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 return nil
} }
err = fsutil.WriteFile(thumbPath, data) err = fsutil.WriteFile(thumbPath, data)
if err != nil { 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 return nil

View File

@@ -10,6 +10,7 @@ var ErrUnsupportedFormat = errors.New("unsupported image format")
type ImageThumbnailOptions struct { type ImageThumbnailOptions struct {
InputFormat ffmpeg.ImageFormat InputFormat ffmpeg.ImageFormat
OutputFormat ffmpeg.ImageFormat
OutputPath string OutputPath string
MaxDimensions int MaxDimensions int
Quality int Quality int
@@ -29,12 +30,15 @@ func ImageThumbnail(input string, options ImageThumbnailOptions) ffmpeg.Args {
VideoFilter(videoFilter). VideoFilter(videoFilter).
VideoCodec(ffmpeg.VideoCodecMJpeg) VideoCodec(ffmpeg.VideoCodecMJpeg)
args = append(args, "-frames:v", "1")
if options.Quality > 0 { if options.Quality > 0 {
args = args.FixedQualityScaleVideo(options.Quality) args = args.FixedQualityScaleVideo(options.Quality)
} }
args = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe). args = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe).
Output(options.OutputPath) Output(options.OutputPath).
ImageFormat(options.OutputFormat)
return args return args
} }

20
pkg/file/frame.go Normal file
View File

@@ -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
}

View File

@@ -9,12 +9,15 @@ import (
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/file/video"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
) )
// Decorator adds image specific fields to a File. // Decorator adds image specific fields to a File.
type Decorator struct { type Decorator struct {
FFProbe ffmpeg.FFProbe
} }
func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file.File, error) { func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file.File, error) {
@@ -25,11 +28,13 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file
} }
defer r.Close() defer r.Close()
probe, err := d.FFProbe.NewVideoFile(base.Path)
if err != nil {
fmt.Printf("Warning: File %q could not be read with ffprobe: %s, assuming ImageFile", base.Path, err)
c, format, err := image.DecodeConfig(r) c, format, err := image.DecodeConfig(r)
if err != nil { if err != nil {
return f, fmt.Errorf("decoding image file %q: %w", base.Path, err) return f, fmt.Errorf("decoding image file %q: %w", base.Path, err)
} }
return &file.ImageFile{ return &file.ImageFile{
BaseFile: base, BaseFile: base,
Format: format, Format: format,
@@ -38,6 +43,26 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file
}, nil }, 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: probe.VideoCodec,
Width: probe.Width,
Height: probe.Height,
}, nil
}
func (d *Decorator) IsMissingMetadata(ctx context.Context, fs file.FS, f file.File) bool { func (d *Decorator) IsMissingMetadata(ctx context.Context, fs file.FS, f file.File) bool {
const ( const (
unsetString = "unset" unsetString = "unset"

View File

@@ -7,3 +7,15 @@ type ImageFile struct {
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` 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
}

View File

@@ -16,13 +16,14 @@ type VideoFile struct {
InteractiveSpeed *int `json:"interactive_speed"` InteractiveSpeed *int `json:"interactive_speed"`
} }
func (f VideoFile) GetMinResolution() int { func (f VideoFile) GetWidth() int {
w := f.Width return f.Width
h := f.Height
if w < h {
return w
} }
return h func (f VideoFile) GetHeight() int {
return f.Height
}
func (f VideoFile) GetFormat() string {
return f.Format
} }

View File

@@ -22,13 +22,19 @@ type FileDeleter struct {
// MarkGeneratedFiles marks for deletion the generated files for the provided image. // MarkGeneratedFiles marks for deletion the generated files for the provided image.
func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error { func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
var files []string
thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth) thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth)
exists, _ := fsutil.FileExists(thumbPath) exists, _ := fsutil.FileExists(thumbPath)
if exists { 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. // 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() { for _, f := range i.Files.List() {
// only delete files where there is no other associated image // 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 { if err != nil {
return err return err
} }
@@ -99,7 +105,7 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter
// don't delete files in zip archives // don't delete files in zip archives
const deleteFile = true 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 { if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil {
return err return err
} }

View File

@@ -45,12 +45,10 @@ var (
func createFullImage(id int) models.Image { func createFullImage(id int) models.Image {
return models.Image{ return models.Image{
ID: id, ID: id,
Files: models.NewRelatedImageFiles([]*file.ImageFile{ Files: models.NewRelatedFiles([]file.File{
{ &file.BaseFile{
BaseFile: &file.BaseFile{
Path: path, Path: path,
}, },
},
}), }),
Title: title, Title: title,
OCounter: ocounter, OCounter: ocounter,

View File

@@ -97,7 +97,7 @@ func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image {
} }
func (i *Importer) populateFiles(ctx context.Context) error { func (i *Importer) populateFiles(ctx context.Context) error {
files := make([]*file.ImageFile, 0) files := make([]file.File, 0)
for _, ref := range i.Input.Files { for _, ref := range i.Input.Files {
path := ref path := ref
@@ -109,11 +109,11 @@ func (i *Importer) populateFiles(ctx context.Context) error {
if f == nil { if f == nil {
return fmt.Errorf("image file '%s' not found", path) return fmt.Errorf("image file '%s' not found", path)
} else { } 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 return nil
} }
@@ -311,7 +311,7 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) {
var err error var err error
for _, f := range i.image.Files.List() { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -29,7 +29,7 @@ type FinderCreatorUpdater interface {
UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error) UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error)
AddFileID(ctx context.Context, id int, fileID file.ID) error AddFileID(ctx context.Context, id int, fileID file.ID) error
models.GalleryIDLoader models.GalleryIDLoader
models.ImageFileLoader models.FileLoader
} }
type GalleryFinderCreator interface { type GalleryFinderCreator interface {
@@ -40,14 +40,17 @@ type GalleryFinderCreator interface {
type ScanConfig interface { type ScanConfig interface {
GetCreateGalleriesFromFolders() bool GetCreateGalleriesFromFolders() bool
IsGenerateThumbnails() bool }
type ScanGenerator interface {
Generate(ctx context.Context, i *models.Image, f file.File) error
} }
type ScanHandler struct { type ScanHandler struct {
CreatorUpdater FinderCreatorUpdater CreatorUpdater FinderCreatorUpdater
GalleryFinder GalleryFinderCreator GalleryFinder GalleryFinderCreator
ThumbnailGenerator ThumbnailGenerator ScanGenerator ScanGenerator
ScanConfig ScanConfig ScanConfig ScanConfig
@@ -60,6 +63,9 @@ func (h *ScanHandler) validate() error {
if h.CreatorUpdater == nil { if h.CreatorUpdater == nil {
return errors.New("CreatorUpdater is required") return errors.New("CreatorUpdater is required")
} }
if h.ScanGenerator == nil {
return errors.New("ScanGenerator is required")
}
if h.GalleryFinder == nil { if h.GalleryFinder == nil {
return errors.New("GalleryFinder is required") 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 return err
} }
imageFile, ok := f.(*file.ImageFile) imageFile := f.Base()
if !ok {
return ErrNotImageFile
}
// try to match the file to an image // try to match the file to an image
existing, err := h.CreatorUpdater.FindByFileID(ctx, imageFile.ID) 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 generation doesn't hold up the transaction
// do this after the commit so that the transaction isn't held up
txn.AddPostCommitHook(ctx, func(ctx context.Context) { txn.AddPostCommitHook(ctx, func(ctx context.Context) {
for _, s := range existing { for _, s := range existing {
if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil { if err := h.ScanGenerator.Generate(ctx, s, f); err != nil {
// just log if cover generation fails. We can try again on rescan // just log if cover generation fails. We can try again on rescan
logger.Errorf("Error generating thumbnail for %s: %v", imageFile.Path, err) logger.Errorf("Error generating content for %s: %v", imageFile.Path, err)
} }
} }
}) })
}
return nil 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 { for _, i := range existing {
if err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil { if err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil {
return err return err
@@ -164,7 +165,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
found := false found := false
for _, sf := range i.Files.List() { for _, sf := range i.Files.List() {
if sf.ID == f.Base().ID { if sf.Base().ID == f.Base().ID {
found = true found = true
break break
} }

View File

@@ -15,7 +15,7 @@ type FinderByFile interface {
type Repository interface { type Repository interface {
FinderByFile FinderByFile
Destroyer Destroyer
models.ImageFileLoader models.FileLoader
} }
type Service struct { type Service struct {

View File

@@ -12,7 +12,6 @@ import (
"github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/ffmpeg/transcoder"
"github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/models"
) )
const ffmpegImageQuality = 5 const ffmpegImageQuality = 5
@@ -27,13 +26,17 @@ var (
ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail") ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail")
) )
type ThumbnailGenerator interface { type ThumbnailEncoder struct {
GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error FFMpeg *ffmpeg.FFMpeg
FFProbe ffmpeg.FFProbe
ClipPreviewOptions ClipPreviewOptions
vips *vipsEncoder
} }
type ThumbnailEncoder struct { type ClipPreviewOptions struct {
ffmpeg *ffmpeg.FFMpeg InputArgs []string
vips *vipsEncoder OutputArgs []string
Preset string
} }
func GetVipsPath() string { func GetVipsPath() string {
@@ -43,9 +46,11 @@ func GetVipsPath() string {
return vipsPath return vipsPath
} }
func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder { func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder {
ret := ThumbnailEncoder{ ret := ThumbnailEncoder{
ffmpeg: ffmpegEncoder, FFMpeg: ffmpegEncoder,
FFProbe: ffProbe,
ClipPreviewOptions: clipPreviewOptions,
} }
vipsPath := GetVipsPath() 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. // 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 // 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. // 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{}) reader, err := f.Open(&file.OsFS{})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -75,8 +80,9 @@ func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte,
data := buf.Bytes() data := buf.Bytes()
format := f.Format if imageFile, ok := f.(*file.ImageFile); ok {
animated := f.Format == formatGif format := imageFile.Format
animated := imageFile.Format == formatGif
// #2266 - if image is webp, then determine if it is animated // #2266 - if image is webp, then determine if it is animated
if format == formatWebP { if format == formatWebP {
@@ -87,35 +93,100 @@ func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte,
if animated { if animated {
return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format) 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 // vips has issues loading files from stdin on Windows
if e.vips != nil && runtime.GOOS != "windows" { if e.vips != nil && runtime.GOOS != "windows" {
return e.vips.ImageThumbnail(buf, maxSize) return e.vips.ImageThumbnail(buf, maxSize)
} else { } 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) { // GetPreview returns the preview clip of the provided image clip resized to
var ffmpegFormat ffmpeg.ImageFormat // 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 { buf := new(bytes.Buffer)
case "jpeg": if _, err := buf.ReadFrom(reader); err != nil {
ffmpegFormat = ffmpeg.ImageFormatJpeg return nil, err
case "png":
ffmpegFormat = ffmpeg.ImageFormatPng
case "webp":
ffmpegFormat = ffmpeg.ImageFormatWebp
default:
return nil, ErrUnsupportedImageFormat
} }
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{ args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{
InputFormat: ffmpegFormat, OutputFormat: ffmpeg.ImageFormatJpeg,
OutputPath: "-", OutputPath: "-",
MaxDimensions: maxSize, MaxDimensions: maxSize,
Quality: ffmpegImageQuality, 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)
} }

View File

@@ -18,6 +18,7 @@ type GenerateMetadataOptions struct {
Transcodes bool `json:"transcodes"` Transcodes bool `json:"transcodes"`
Phashes bool `json:"phashes"` Phashes bool `json:"phashes"`
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
ClipPreviews bool `json:"clipPreviews"`
} }
type GeneratePreviewOptions struct { type GeneratePreviewOptions struct {

View File

@@ -2,7 +2,6 @@ package models
import ( import (
"context" "context"
"errors"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "time"
@@ -24,7 +23,7 @@ type Image struct {
Date *Date `json:"date"` Date *Date `json:"date"`
// transient - not persisted // transient - not persisted
Files RelatedImageFiles Files RelatedFiles
PrimaryFileID *file.ID PrimaryFileID *file.ID
// transient - path of primary file - empty if no files // transient - path of primary file - empty if no files
Path string Path string
@@ -39,14 +38,14 @@ type Image struct {
PerformerIDs RelatedIDs `json:"performer_ids"` PerformerIDs RelatedIDs `json:"performer_ids"`
} }
func (i *Image) LoadFiles(ctx context.Context, l ImageFileLoader) error { func (i *Image) LoadFiles(ctx context.Context, l FileLoader) error {
return i.Files.load(func() ([]*file.ImageFile, error) { return i.Files.load(func() ([]file.File, error) {
return l.GetFiles(ctx, i.ID) return l.GetFiles(ctx, i.ID)
}) })
} }
func (i *Image) LoadPrimaryFile(ctx context.Context, l file.Finder) error { 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 { if i.PrimaryFileID == nil {
return nil, nil return nil, nil
} }
@@ -56,15 +55,11 @@ func (i *Image) LoadPrimaryFile(ctx context.Context, l file.Finder) error {
return nil, err return nil, err
} }
var vf *file.ImageFile
if len(f) > 0 { if len(f) > 0 {
var ok bool return f[0], nil
vf, ok = f[0].(*file.ImageFile)
if !ok {
return nil, errors.New("not an image file")
} }
}
return vf, nil return nil, nil
}) })
} }

View File

@@ -78,3 +78,8 @@ func (gp *generatedPaths) GetThumbnailPath(checksum string, width int) string {
fname := fmt.Sprintf("%s_%d.jpg", checksum, width) fname := fmt.Sprintf("%s_%d.jpg", checksum, width)
return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) 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)
}

View File

@@ -34,10 +34,6 @@ type VideoFileLoader interface {
GetFiles(ctx context.Context, relatedID int) ([]*file.VideoFile, error) GetFiles(ctx context.Context, relatedID int) ([]*file.VideoFile, error)
} }
type ImageFileLoader interface {
GetFiles(ctx context.Context, relatedID int) ([]*file.ImageFile, error)
}
type FileLoader interface { type FileLoader interface {
GetFiles(ctx context.Context, relatedID int) ([]file.File, error) 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 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 { type RelatedFiles struct {
primaryFile file.File primaryFile file.File
files []file.File files []file.File

View File

@@ -241,7 +241,7 @@ func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) e
if updatedObject.Files.Loaded() { if updatedObject.Files.Loaded() {
fileIDs := make([]file.ID, len(updatedObject.Files.List())) fileIDs := make([]file.ID, len(updatedObject.Files.List()))
for i, f := range 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 { 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 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) fileIDs, err := qb.filesRepository().get(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -372,16 +372,7 @@ func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile,
return nil, err return nil, err
} }
ret := make([]*file.ImageFile, len(files)) return files, nil
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
} }
func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) { func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) {

View File

@@ -97,7 +97,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) {
Organized: true, Organized: true,
OCounter: ocounter, OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage], StudioID: &studioIDs[studioIdxWithImage],
Files: models.NewRelatedImageFiles([]*file.ImageFile{ Files: models.NewRelatedFiles([]file.File{
imageFile.(*file.ImageFile), imageFile.(*file.ImageFile),
}), }),
PrimaryFileID: &imageFile.Base().ID, PrimaryFileID: &imageFile.Base().ID,
@@ -149,7 +149,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) {
var fileIDs []file.ID var fileIDs []file.ID
if tt.newObject.Files.Loaded() { if tt.newObject.Files.Loaded() {
for _, f := range tt.newObject.Files.List() { for _, f := range tt.newObject.Files.List() {
fileIDs = append(fileIDs, f.ID) fileIDs = append(fileIDs, f.Base().ID)
} }
} }
s := tt.newObject s := tt.newObject
@@ -444,7 +444,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
Organized: true, Organized: true,
OCounter: ocounter, OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage], StudioID: &studioIDs[studioIdxWithImage],
Files: models.NewRelatedImageFiles([]*file.ImageFile{ Files: models.NewRelatedFiles([]file.File{
makeImageFile(imageIdx1WithGallery), makeImageFile(imageIdx1WithGallery),
}), }),
CreatedAt: createdAt, CreatedAt: createdAt,
@@ -462,7 +462,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
models.Image{ models.Image{
ID: imageIDs[imageIdx1WithGallery], ID: imageIDs[imageIdx1WithGallery],
OCounter: getOCounter(imageIdx1WithGallery), OCounter: getOCounter(imageIdx1WithGallery),
Files: models.NewRelatedImageFiles([]*file.ImageFile{ Files: models.NewRelatedFiles([]file.File{
makeImageFile(imageIdx1WithGallery), makeImageFile(imageIdx1WithGallery),
}), }),
GalleryIDs: models.NewRelatedIDs([]int{}), GalleryIDs: models.NewRelatedIDs([]int{}),
@@ -965,7 +965,7 @@ func makeImageWithID(index int) *models.Image {
ret := makeImage(index, true) ret := makeImage(index, true)
ret.ID = imageIDs[index] ret.ID = imageIDs[index]
ret.Files = models.NewRelatedImageFiles([]*file.ImageFile{makeImageFile(index)}) ret.Files = models.NewRelatedFiles([]file.File{makeImageFile(index)})
return ret return ret
} }
@@ -1868,8 +1868,11 @@ func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) {
t.Errorf("Error loading primary file: %s", err.Error()) t.Errorf("Error loading primary file: %s", err.Error())
return nil return nil
} }
asFrame, ok := image.Files.Primary().(file.VisualFile)
verifyImageResolution(t, image.Files.Primary().Height, resolution) if !ok {
t.Errorf("Error: Associated primary file of image is not of type VisualFile")
}
verifyImageResolution(t, asFrame.GetHeight(), resolution)
} }
return nil return nil

View File

@@ -347,6 +347,10 @@ func getResolution() (int, int) {
return w, h return w, h
} }
func getBool() {
return rand.Intn(2) == 0
}
func getDate() time.Time { func getDate() time.Time {
s := rand.Int63n(time.Now().Unix()) s := rand.Int63n(time.Now().Unix())
@@ -371,6 +375,7 @@ func generateImageFile(parentFolderID file.FolderID, path string) file.File {
BaseFile: generateBaseFile(parentFolderID, path), BaseFile: generateBaseFile(parentFolderID, path),
Height: h, Height: h,
Width: w, Width: w,
Clip: getBool(),
} }
} }

View File

@@ -67,8 +67,8 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
images.forEach((image, index) => { images.forEach((image, index) => {
let imageData = { let imageData = {
src: image.paths.thumbnail!, src: image.paths.thumbnail!,
width: image.files[0].width, width: image.visual_files[0].width,
height: image.files[0].height, height: image.visual_files[0].height,
tabIndex: index, tabIndex: index,
key: image.id ?? index, key: image.id ?? index,
loading: "lazy", loading: "lazy",

View File

@@ -6,7 +6,7 @@ import AutoTagging from "src/docs/en/Manual/AutoTagging.md";
import JSONSpec from "src/docs/en/Manual/JSONSpec.md"; import JSONSpec from "src/docs/en/Manual/JSONSpec.md";
import Configuration from "src/docs/en/Manual/Configuration.md"; import Configuration from "src/docs/en/Manual/Configuration.md";
import Interface from "src/docs/en/Manual/Interface.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 Scraping from "src/docs/en/Manual/Scraping.md";
import ScraperDevelopment from "src/docs/en/Manual/ScraperDevelopment.md"; import ScraperDevelopment from "src/docs/en/Manual/ScraperDevelopment.md";
import Plugins from "src/docs/en/Manual/Plugins.md"; import Plugins from "src/docs/en/Manual/Plugins.md";
@@ -88,9 +88,9 @@ export const Manual: React.FC<IManualProps> = ({
content: Browsing, content: Browsing,
}, },
{ {
key: "Galleries.md", key: "Images.md",
title: "Image Galleries", title: "Images and Galleries",
content: Galleries, content: Images,
}, },
{ {
key: "Scraping.md", key: "Scraping.md",

View File

@@ -30,7 +30,10 @@ export const ImageCard: React.FC<IImageCardProps> = (
props: IImageCardProps props: IImageCardProps
) => { ) => {
const file = useMemo( 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] [props.image]
); );
@@ -138,6 +141,13 @@ export const ImageCard: React.FC<IImageCardProps> = (
return height > width; 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 ( return (
<GridCard <GridCard
className={`image-card zoom-${props.zoomIndex}`} className={`image-card zoom-${props.zoomIndex}`}
@@ -147,10 +157,12 @@ export const ImageCard: React.FC<IImageCardProps> = (
image={ image={
<> <>
<div className={cx("image-card-preview", { portrait: isPortrait() })}> <div className={cx("image-card-preview", { portrait: isPortrait() })}>
<img <ImagePreview
loop={video}
autoPlay={video}
className="image-card-preview-image" className="image-card-preview-image"
alt={props.image.title ?? ""} alt={props.image.title ?? ""}
src={props.image.paths.thumbnail ?? ""} src={source}
/> />
{props.onPreview ? ( {props.onPreview ? (
<div className="preview-button"> <div className="preview-button">

View File

@@ -51,7 +51,7 @@ export const Image: React.FC = () => {
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
async function onRescan() { async function onRescan() {
if (!image || !image.files.length) { if (!image || !image.visual_files.length) {
return; return;
} }
@@ -181,8 +181,8 @@ export const Image: React.FC = () => {
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="image-file-info-panel"> <Nav.Link eventKey="image-file-info-panel">
<FormattedMessage id="file_info" /> <FormattedMessage id="file_info" />
{image.files.length > 1 && ( {image.visual_files.length > 1 && (
<Counter count={image.files.length ?? 0} /> <Counter count={image.visual_files.length ?? 0} />
)} )}
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
@@ -260,6 +260,8 @@ export const Image: React.FC = () => {
} }
const title = objectTitle(image); const title = objectTitle(image);
const ImageView =
image.visual_files[0].__typename == "VideoFile" ? "video" : "img";
return ( return (
<div className="row"> <div className="row">
@@ -286,8 +288,16 @@ export const Image: React.FC = () => {
{renderTabs()} {renderTabs()}
</div> </div>
<div className="image-container"> <div className="image-container">
<img <ImageView
loop={image.visual_files[0].__typename == "VideoFile"}
autoPlay={image.visual_files[0].__typename == "VideoFile"}
controls={image.visual_files[0].__typename == "VideoFile"}
className="m-sm-auto no-gutter image-image" className="m-sm-auto no-gutter image-image"
style={
image.visual_files[0].__typename == "VideoFile"
? { width: "100%", height: "100%" }
: {}
}
alt={title} alt={title}
src={image.paths.image ?? ""} src={image.paths.image ?? ""}
/> />

View File

@@ -10,7 +10,7 @@ import TextUtils from "src/utils/text";
import { TextField, URLField } from "src/utils/field"; import { TextField, URLField } from "src/utils/field";
interface IFileInfoPanelProps { interface IFileInfoPanelProps {
file: GQL.ImageFileDataFragment; file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment;
primary?: boolean; primary?: boolean;
ofMany?: boolean; ofMany?: boolean;
onSetPrimaryFile?: () => void; onSetPrimaryFile?: () => void;
@@ -110,17 +110,17 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [deletingFile, setDeletingFile] = useState< 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 <></>; return <></>;
} }
if (props.image.files.length === 1) { if (props.image.visual_files.length === 1) {
return ( return (
<> <>
<FileInfoPanel file={props.image.files[0]} /> <FileInfoPanel file={props.image.visual_files[0]} />
{props.image.url ? ( {props.image.url ? (
<dl className="container image-file-info details-list"> <dl className="container image-file-info details-list">
@@ -150,14 +150,14 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
} }
return ( return (
<Accordion defaultActiveKey={props.image.files[0].id}> <Accordion defaultActiveKey={props.image.visual_files[0].id}>
{deletingFile && ( {deletingFile && (
<DeleteFilesDialog <DeleteFilesDialog
onClose={() => setDeletingFile(undefined)} onClose={() => setDeletingFile(undefined)}
selected={[deletingFile]} selected={[deletingFile]}
/> />
)} )}
{props.image.files.map((file, index) => ( {props.image.visual_files.map((file, index) => (
<Card key={file.id} className="image-file-card"> <Card key={file.id} className="image-file-card">
<Accordion.Toggle as={Card.Header} eventKey={file.id}> <Accordion.Toggle as={Card.Header} eventKey={file.id}>
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} /> <TruncatedText text={TextUtils.fileNameFromPath(file.path)} />

View File

@@ -22,6 +22,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { ImageCard } from "./ImageCard"; import { ImageCard } from "./ImageCard";
import { ImageWallItem } from "./ImageWallItem";
import { EditImagesDialog } from "./EditImagesDialog"; import { EditImagesDialog } from "./EditImagesDialog";
import { DeleteImagesDialog } from "./DeleteImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog";
import "flexbin/flexbin.css"; import "flexbin/flexbin.css";
@@ -56,9 +57,12 @@ const ImageWall: React.FC<IImageWallProps> = ({ images, handleImageOpen }) => {
images.forEach((image, index) => { images.forEach((image, index) => {
let imageData = { let imageData = {
src: image.paths.thumbnail!, src:
width: image.files[0].width, image.paths.preview != ""
height: image.files[0].height, ? image.paths.preview!
: image.paths.thumbnail!,
width: image.visual_files[0].width,
height: image.visual_files[0].height,
tabIndex: index, tabIndex: index,
key: image.id, key: image.id,
loading: "lazy", loading: "lazy",
@@ -86,6 +90,7 @@ const ImageWall: React.FC<IImageWallProps> = ({ images, handleImageOpen }) => {
{photos.length ? ( {photos.length ? (
<Gallery <Gallery
photos={photos} photos={photos}
renderImage={ImageWallItem}
onClick={showLightboxOnClick} onClick={showLightboxOnClick}
margin={uiConfig?.imageWallOptions?.margin!} margin={uiConfig?.imageWallOptions?.margin!}
direction={uiConfig?.imageWallOptions?.direction!} direction={uiConfig?.imageWallOptions?.direction!}

View File

@@ -0,0 +1,57 @@
import React from "react";
import type {
RenderImageProps,
renderImageClickHandler,
PhotoProps,
} from "react-photo-gallery";
interface IImageWallProps {
margin?: string;
index: number;
photo: PhotoProps;
onClick: renderImageClickHandler | null;
direction: "row" | "column";
top?: number;
left?: number;
}
export const ImageWallItem: React.FC<RenderImageProps> = (
props: IImageWallProps
) => {
type style = Record<string, string | number | undefined>;
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<Element, MouseEvent>
) {
if (props.onClick) {
props.onClick(event, { index: props.index });
}
};
const video = props.photo.src.includes("preview");
const ImagePreview = video ? "video" : "img";
return (
<ImagePreview
loop={video}
autoPlay={video}
key={props.photo.key}
style={imgStyle}
src={props.photo.src}
width={props.photo.width}
height={props.photo.height}
alt={props.photo.alt}
onClick={handleClick}
/>
);
};

View File

@@ -130,6 +130,14 @@ export const SettingsLibraryPanel: React.FC = () => {
onChange={(v) => saveGeneral({ writeImageThumbnails: v })} onChange={(v) => saveGeneral({ writeImageThumbnails: v })}
/> />
<BooleanSetting
id="create-image-clips-from-videos"
headingID="config.ui.images.options.create_image_clips_from_videos.heading"
subHeadingID="config.ui.images.options.create_image_clips_from_videos.description"
checked={general.createImageClipsFromVideos ?? false}
onChange={(v) => saveGeneral({ createImageClipsFromVideos: v })}
/>
<StringSetting <StringSetting
id="gallery-cover-regex" id="gallery-cover-regex"
headingID="config.general.gallery_cover_regex_label" headingID="config.general.gallery_cover_regex_label"

View File

@@ -142,6 +142,12 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
headingID="dialogs.scene_gen.interactive_heatmap_speed" headingID="dialogs.scene_gen.interactive_heatmap_speed"
onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })} onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })}
/> />
<BooleanSetting
id="clip-previews"
checked={options.clipPreviews ?? false}
headingID="dialogs.scene_gen.clip_previews"
onChange={(v) => setOptions({ clipPreviews: v })}
/>
<BooleanSetting <BooleanSetting
id="overwrite" id="overwrite"
checked={options.overwrite ?? false} checked={options.overwrite ?? false}

View File

@@ -18,6 +18,7 @@ export const ScanOptions: React.FC<IScanOptions> = ({
scanGenerateSprites, scanGenerateSprites,
scanGeneratePhashes, scanGeneratePhashes,
scanGenerateThumbnails, scanGenerateThumbnails,
scanGenerateClipPreviews,
} = options; } = options;
function setOptions(input: Partial<GQL.ScanMetadataInput>) { function setOptions(input: Partial<GQL.ScanMetadataInput>) {
@@ -68,6 +69,12 @@ export const ScanOptions: React.FC<IScanOptions> = ({
headingID="config.tasks.generate_thumbnails_during_scan" headingID="config.tasks.generate_thumbnails_during_scan"
onChange={(v) => setOptions({ scanGenerateThumbnails: v })} onChange={(v) => setOptions({ scanGenerateThumbnails: v })}
/> />
<BooleanSetting
id="scan-generate-clip-previews"
checked={scanGenerateClipPreviews ?? false}
headingID="config.tasks.generate_clip_previews_during_scan"
onChange={(v) => setOptions({ scanGenerateClipPreviews: v })}
/>
</> </>
); );
}; };

View File

@@ -88,6 +88,10 @@ const typePolicies: TypePolicies = {
}, },
}; };
const possibleTypes = {
VisualFile: ["VideoFile", "ImageFile"],
};
export const baseURL = export const baseURL =
document.querySelector("base")?.getAttribute("href") ?? "/"; document.querySelector("base")?.getAttribute("href") ?? "/";
@@ -156,7 +160,10 @@ export const createClient = () => {
const link = from([errorLink, splitLink]); const link = from([errorLink, splitLink]);
const cache = new InMemoryCache({ typePolicies }); const cache = new InMemoryCache({
typePolicies,
possibleTypes: possibleTypes,
});
const client = new ApolloClient({ const client = new ApolloClient({
link, link,
cache, cache,

View File

@@ -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.

View File

@@ -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.

View File

@@ -20,6 +20,7 @@ The scan task accepts the following options:
| Generate scrubber sprites | Generates sprites for the scene scrubber. | | Generate scrubber sprites | Generates sprites for the scene scrubber. |
| Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. | | Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
| Generate thumbnails for images | Generates thumbnails for image files. | | 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 # Auto Tagging
See the [Auto Tagging](/help/AutoTagging.md) page. 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. | | 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. | | 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. | | 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. | | 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 ## Transcodes

View File

@@ -425,20 +425,25 @@ export const LightboxComponent: React.FC<IProps> = ({
} }
} }
const navItems = images.map((image, i) => ( const navItems = images.map((image, i) =>
<img React.createElement(image.paths.preview != "" ? "video" : "img", {
src={image.paths.thumbnail ?? ""} loop: image.paths.preview != "",
alt="" autoPlay: image.paths.preview != "",
className={cx(CLASSNAME_NAVIMAGE, { src:
image.paths.preview != ""
? image.paths.preview ?? ""
: image.paths.thumbnail ?? "",
alt: "",
className: cx(CLASSNAME_NAVIMAGE, {
[CLASSNAME_NAVSELECTED]: i === index, [CLASSNAME_NAVSELECTED]: i === index,
})} }),
onClick={(e: React.MouseEvent) => selectIndex(e, i)} onClick: (e: React.MouseEvent) => selectIndex(e, i),
role="presentation" role: "presentation",
loading="lazy" loading: "lazy",
key={image.paths.thumbnail} key: image.paths.thumbnail,
onLoad={imageLoaded} onLoad: imageLoaded,
/> })
)); );
const onDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => { const onDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let numberValue = Number.parseInt(e.currentTarget.value, 10); let numberValue = Number.parseInt(e.currentTarget.value, 10);
@@ -845,6 +850,7 @@ export const LightboxComponent: React.FC<IProps> = ({
scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} scrollAttemptsBeforeChange={scrollAttemptsBeforeChange}
setZoom={(v) => setZoom(v)} setZoom={(v) => setZoom(v)}
resetPosition={resetPosition} resetPosition={resetPosition}
isVideo={image.visual_files?.[0]?.__typename == "VideoFile"}
/> />
) : undefined} ) : undefined}
</div> </div>

View File

@@ -59,6 +59,7 @@ interface IProps {
setZoom: (v: number) => void; setZoom: (v: number) => void;
onLeft: () => void; onLeft: () => void;
onRight: () => void; onRight: () => void;
isVideo: boolean;
} }
export const LightboxImage: React.FC<IProps> = ({ export const LightboxImage: React.FC<IProps> = ({
@@ -74,6 +75,7 @@ export const LightboxImage: React.FC<IProps> = ({
current, current,
setZoom, setZoom,
resetPosition, resetPosition,
isVideo,
}) => { }) => {
const [defaultZoom, setDefaultZoom] = useState(1); const [defaultZoom, setDefaultZoom] = useState(1);
const [moving, setMoving] = useState(false); const [moving, setMoving] = useState(false);
@@ -89,7 +91,7 @@ export const LightboxImage: React.FC<IProps> = ({
const container = React.createRef<HTMLDivElement>(); const container = React.createRef<HTMLDivElement>();
const startPoints = useRef<number[]>([0, 0]); const startPoints = useRef<number[]>([0, 0]);
const pointerCache = useRef<React.PointerEvent<HTMLDivElement>[]>([]); const pointerCache = useRef<React.PointerEvent[]>([]);
const prevDiff = useRef<number | undefined>(); const prevDiff = useRef<number | undefined>();
const scrollAttempts = useRef(0); const scrollAttempts = useRef(0);
@@ -100,6 +102,24 @@ export const LightboxImage: React.FC<IProps> = ({
setBoxWidth(box.offsetWidth); setBoxWidth(box.offsetWidth);
setBoxHeight(box.offsetHeight); 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]); }, [container]);
useEffect(() => { useEffect(() => {
@@ -233,7 +253,12 @@ export const LightboxImage: React.FC<IProps> = ({
calculateInitialPosition, calculateInitialPosition,
]); ]);
function getScrollMode(ev: React.WheelEvent<HTMLDivElement>) { function getScrollMode(
ev:
| React.WheelEvent<HTMLImageElement>
| React.WheelEvent<HTMLVideoElement>
| React.WheelEvent<HTMLDivElement>
) {
if (ev.shiftKey) { if (ev.shiftKey) {
switch (scrollMode) { switch (scrollMode) {
case GQL.ImageLightboxScrollMode.Zoom: case GQL.ImageLightboxScrollMode.Zoom:
@@ -246,14 +271,24 @@ export const LightboxImage: React.FC<IProps> = ({
return scrollMode; return scrollMode;
} }
function onContainerScroll(ev: React.WheelEvent<HTMLDivElement>) { function onContainerScroll(
ev:
| React.WheelEvent<HTMLImageElement>
| React.WheelEvent<HTMLVideoElement>
| React.WheelEvent<HTMLDivElement>
) {
// don't zoom if mouse isn't over image // don't zoom if mouse isn't over image
if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) { if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) {
onImageScroll(ev); onImageScroll(ev);
} }
} }
function onImageScrollPanY(ev: React.WheelEvent<HTMLDivElement>) { function onImageScrollPanY(
ev:
| React.WheelEvent<HTMLImageElement>
| React.WheelEvent<HTMLVideoElement>
| React.WheelEvent<HTMLDivElement>
) {
if (current) { if (current) {
const [minY, maxY] = minMaxY(zoom * defaultZoom); const [minY, maxY] = minMaxY(zoom * defaultZoom);
@@ -298,7 +333,12 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onImageScroll(ev: React.WheelEvent<HTMLDivElement>) { function onImageScroll(
ev:
| React.WheelEvent<HTMLImageElement>
| React.WheelEvent<HTMLVideoElement>
| React.WheelEvent<HTMLDivElement>
) {
const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
switch (getScrollMode(ev)) { switch (getScrollMode(ev)) {
@@ -311,7 +351,11 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onImageMouseOver(ev: React.MouseEvent<HTMLDivElement, MouseEvent>) { function onImageMouseOver(
ev:
| React.MouseEvent<HTMLImageElement, MouseEvent>
| React.MouseEvent<HTMLVideoElement, MouseEvent>
) {
if (!moving) return; if (!moving) return;
if (!ev.buttons) { if (!ev.buttons) {
@@ -327,14 +371,22 @@ export const LightboxImage: React.FC<IProps> = ({
setPositionY(positionY + posY); setPositionY(positionY + posY);
} }
function onImageMouseDown(ev: React.MouseEvent<HTMLDivElement, MouseEvent>) { function onImageMouseDown(
ev:
| React.MouseEvent<HTMLImageElement, MouseEvent>
| React.MouseEvent<HTMLVideoElement, MouseEvent>
) {
startPoints.current = [ev.pageX, ev.pageY]; startPoints.current = [ev.pageX, ev.pageY];
setMoving(true); setMoving(true);
mouseDownEvent.current = ev.nativeEvent; mouseDownEvent.current = ev.nativeEvent;
} }
function onImageMouseUp(ev: React.MouseEvent<HTMLDivElement, MouseEvent>) { function onImageMouseUp(
ev:
| React.MouseEvent<HTMLImageElement, MouseEvent>
| React.MouseEvent<HTMLVideoElement, MouseEvent>
) {
if (ev.button !== 0) return; if (ev.button !== 0) return;
if ( if (
@@ -360,7 +412,12 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onTouchStart(ev: React.TouchEvent<HTMLDivElement>) { function onTouchStart(
ev:
| React.TouchEvent<HTMLImageElement>
| React.TouchEvent<HTMLVideoElement>
| React.TouchEvent<HTMLDivElement>
) {
ev.preventDefault(); ev.preventDefault();
if (ev.touches.length === 1) { if (ev.touches.length === 1) {
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
@@ -368,7 +425,12 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onTouchMove(ev: React.TouchEvent<HTMLDivElement>) { function onTouchMove(
ev:
| React.TouchEvent<HTMLImageElement>
| React.TouchEvent<HTMLVideoElement>
| React.TouchEvent<HTMLDivElement>
) {
if (!moving) return; if (!moving) return;
if (ev.touches.length === 1) { if (ev.touches.length === 1) {
@@ -381,7 +443,12 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onPointerDown(ev: React.PointerEvent<HTMLDivElement>) { function onPointerDown(
ev:
| React.PointerEvent<HTMLImageElement>
| React.PointerEvent<HTMLVideoElement>
| React.PointerEvent<HTMLDivElement>
) {
// replace pointer event with the same id, if applicable // replace pointer event with the same id, if applicable
pointerCache.current = pointerCache.current.filter( pointerCache.current = pointerCache.current.filter(
(e) => e.pointerId !== ev.pointerId (e) => e.pointerId !== ev.pointerId
@@ -391,7 +458,12 @@ export const LightboxImage: React.FC<IProps> = ({
prevDiff.current = undefined; prevDiff.current = undefined;
} }
function onPointerUp(ev: React.PointerEvent<HTMLDivElement>) { function onPointerUp(
ev:
| React.PointerEvent<HTMLImageElement>
| React.PointerEvent<HTMLVideoElement>
| React.PointerEvent<HTMLDivElement>
) {
for (let i = 0; i < pointerCache.current.length; i++) { for (let i = 0; i < pointerCache.current.length; i++) {
if (pointerCache.current[i].pointerId === ev.pointerId) { if (pointerCache.current[i].pointerId === ev.pointerId) {
pointerCache.current.splice(i, 1); pointerCache.current.splice(i, 1);
@@ -400,7 +472,12 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onPointerMove(ev: React.PointerEvent<HTMLDivElement>) { function onPointerMove(
ev:
| React.PointerEvent<HTMLImageElement>
| React.PointerEvent<HTMLVideoElement>
| React.PointerEvent<HTMLDivElement>
) {
// find the event in the cache // find the event in the cache
const cachedIndex = pointerCache.current.findIndex( const cachedIndex = pointerCache.current.findIndex(
(c) => c.pointerId === ev.pointerId (c) => c.pointerId === ev.pointerId
@@ -432,6 +509,17 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
const ImageView = isVideo ? "video" : "img";
const customStyle = isVideo
? {
touchAction: "none",
display: "flex",
margin: "auto",
width: "100%",
"max-height": "90vh",
}
: { touchAction: "none" };
return ( return (
<div <div
ref={container} ref={container}
@@ -448,11 +536,12 @@ export const LightboxImage: React.FC<IProps> = ({
> >
<source srcSet={src} media="(min-width: 800px)" /> <source srcSet={src} media="(min-width: 800px)" />
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<img <ImageView
loop={isVideo}
src={src} src={src}
alt="" alt=""
draggable={false} draggable={false}
style={{ touchAction: "none" }} style={customStyle}
onWheel={current ? (e) => onImageScroll(e) : undefined} onWheel={current ? (e) => onImageScroll(e) : undefined}
onMouseDown={(e) => onImageMouseDown(e)} onMouseDown={(e) => onImageMouseDown(e)}
onMouseUp={(e) => onImageMouseUp(e)} onMouseUp={(e) => onImageMouseUp(e)}

View File

@@ -3,6 +3,13 @@ import * as GQL from "src/core/generated-graphql";
interface IImagePaths { interface IImagePaths {
image?: GQL.Maybe<string>; image?: GQL.Maybe<string>;
thumbnail?: GQL.Maybe<string>; thumbnail?: GQL.Maybe<string>;
preview?: GQL.Maybe<string>;
}
interface IFiles {
__typename?: string;
width: number;
height: number;
} }
export interface ILightboxImage { export interface ILightboxImage {
@@ -11,6 +18,7 @@ export interface ILightboxImage {
rating100?: GQL.Maybe<number>; rating100?: GQL.Maybe<number>;
o_counter?: GQL.Maybe<number>; o_counter?: GQL.Maybe<number>;
paths: IImagePaths; paths: IImagePaths;
visual_files?: GQL.Maybe<IFiles>[];
} }
export interface IChapter { export interface IChapter {

View File

@@ -422,6 +422,7 @@
"generating_from_paths": "Generating for scenes from the following paths", "generating_from_paths": "Generating for scenes from the following paths",
"generating_scenes": "Generating for {num} {scene}" "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_desc": "Generate supporting image, sprite, video, vtt and other files.",
"generate_phashes_during_scan": "Generate perceptual hashes", "generate_phashes_during_scan": "Generate perceptual hashes",
"generate_phashes_during_scan_tooltip": "For deduplication and scene identification.", "generate_phashes_during_scan_tooltip": "For deduplication and scene identification.",
@@ -592,6 +593,10 @@
"write_image_thumbnails": { "write_image_thumbnails": {
"description": "Write image thumbnails to disk when generated on-the-fly", "description": "Write image thumbnails to disk when generated on-the-fly",
"heading": "Write image thumbnails" "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" "destination": "Reassign to"
}, },
"scene_gen": { "scene_gen": {
"clip_previews": "Image Clip Previews",
"covers": "Scene covers", "covers": "Scene covers",
"force_transcodes": "Force Transcode generation", "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.", "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.",