mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Support image clips/gifs (#3583)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -43,4 +43,46 @@ fragment GalleryFileData on GalleryFile {
|
|||||||
type
|
type
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -73,12 +73,14 @@ type ImageFile implements BaseFile {
|
|||||||
fingerprints: [Fingerprint!]!
|
fingerprints: [Fingerprint!]!
|
||||||
|
|
||||||
width: Int!
|
width: Int!
|
||||||
height: Int!
|
height: Int!
|
||||||
|
|
||||||
created_at: Time!
|
created_at: Time!
|
||||||
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!
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +97,4 @@ type FindImagesResultType {
|
|||||||
"""Total file size in bytes"""
|
"""Total file size in bytes"""
|
||||||
filesize: Float!
|
filesize: Float!
|
||||||
images: [Image!]!
|
images: [Image!]!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 +306,10 @@ 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{
|
||||||
Filter: file.FilterFunc(imageFileFilter),
|
FFProbe: instance.FFProbe,
|
||||||
|
},
|
||||||
|
Filter: file.FilterFunc(imageFileFilter),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
FingerprintCalculator: &fingerprintCalculator{instance.Config},
|
FingerprintCalculator: &fingerprintCalculator{instance.Config},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
68
internal/manager/task_generate_clip_preview.go
Normal file
68
internal/manager/task_generate_clip_preview.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -306,17 +306,14 @@ 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
|
||||||
@@ -325,11 +322,16 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
|
|||||||
&file.FilteredHandler{
|
&file.FilteredHandler{
|
||||||
Filter: file.FilterFunc(imageFileFilter),
|
Filter: file.FilterFunc(imageFileFilter),
|
||||||
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
|
||||||
|
|||||||
@@ -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
20
pkg/file/frame.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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,16 +28,38 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file
|
|||||||
}
|
}
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
|
|
||||||
c, format, err := image.DecodeConfig(r)
|
probe, err := d.FFProbe.NewVideoFile(base.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return f, fmt.Errorf("decoding image file %q: %w", base.Path, err)
|
fmt.Printf("Warning: File %q could not be read with ffprobe: %s, assuming ImageFile", base.Path, err)
|
||||||
|
c, format, err := image.DecodeConfig(r)
|
||||||
|
if err != nil {
|
||||||
|
return f, fmt.Errorf("decoding image file %q: %w", base.Path, err)
|
||||||
|
}
|
||||||
|
return &file.ImageFile{
|
||||||
|
BaseFile: base,
|
||||||
|
Format: format,
|
||||||
|
Width: c.Width,
|
||||||
|
Height: c.Height,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isClip := true
|
||||||
|
// This list is derived from ffmpegImageThumbnail in pkg/image/thumbnail. If one gets updated, the other should be as well
|
||||||
|
for _, item := range []string{"png", "mjpeg", "webp"} {
|
||||||
|
if item == probe.VideoCodec {
|
||||||
|
isClip = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isClip {
|
||||||
|
videoFileDecorator := video.Decorator{FFProbe: d.FFProbe}
|
||||||
|
return videoFileDecorator.Decorate(ctx, fs, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &file.ImageFile{
|
return &file.ImageFile{
|
||||||
BaseFile: base,
|
BaseFile: base,
|
||||||
Format: format,
|
Format: probe.VideoCodec,
|
||||||
Width: c.Width,
|
Width: probe.Width,
|
||||||
Height: c.Height,
|
Height: probe.Height,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
func (f VideoFile) GetHeight() int {
|
||||||
return w
|
return f.Height
|
||||||
}
|
}
|
||||||
|
|
||||||
return h
|
func (f VideoFile) GetFormat() string {
|
||||||
|
return f.Format
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,9 @@ 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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.ScanGenerator.Generate(ctx, s, f); err != nil {
|
||||||
if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); 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 content for %s: %v", imageFile.Path, err)
|
||||||
logger.Errorf("Error generating thumbnail 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,47 +80,113 @@ 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 {
|
||||||
animated = isWebPAnimated(data)
|
animated = isWebPAnimated(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #2266 - don't generate a thumbnail for animated images
|
||||||
|
if animated {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #2266 - don't generate a thumbnail for animated images
|
// Videofiles can only be thumbnailed with ffmpeg
|
||||||
if animated {
|
if _, ok := f.(*file.VideoFile); ok {
|
||||||
return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
|||||||
@@ -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!}
|
||||||
|
|||||||
57
ui/v2.5/src/components/Images/ImageWallItem.tsx
Normal file
57
ui/v2.5/src/components/Images/ImageWallItem.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|
||||||
27
ui/v2.5/src/docs/en/Manual/Images.md
Normal file
27
ui/v2.5/src/docs/en/Manual/Images.md
Normal 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.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user