Support image clips/gifs (#3583)

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

View File

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

View File

@@ -44,3 +44,45 @@ fragment GalleryFileData on GalleryFile {
value
}
}
fragment VisualFileData on VisualFile {
... on BaseFile {
id
path
size
mod_time
fingerprints {
type
value
}
}
... on ImageFile {
id
path
size
mod_time
width
height
fingerprints {
type
value
}
}
... on VideoFile {
id
path
size
mod_time
duration
video_codec
audio_codec
width
height
frame_rate
bit_rate
fingerprints {
type
value
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,42 +12,55 @@ import (
"github.com/stashapp/stash/pkg/models"
)
func (r *imageResolver) getPrimaryFile(ctx context.Context, obj *models.Image) (*file.ImageFile, error) {
func convertImageFile(f *file.ImageFile) *ImageFile {
ret := &ImageFile{
ID: strconv.Itoa(int(f.ID)),
Path: f.Path,
Basename: f.Basename,
ParentFolderID: strconv.Itoa(int(f.ParentFolderID)),
ModTime: f.ModTime,
Size: f.Size,
Width: f.Width,
Height: f.Height,
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
Fingerprints: resolveFingerprints(f.Base()),
}
if f.ZipFileID != nil {
zipFileID := strconv.Itoa(int(*f.ZipFileID))
ret.ZipFileID = &zipFileID
}
return ret
}
func (r *imageResolver) getPrimaryFile(ctx context.Context, obj *models.Image) (file.VisualFile, error) {
if obj.PrimaryFileID != nil {
f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID)
if err != nil {
return nil, err
}
ret, ok := f.(*file.ImageFile)
asFrame, ok := f.(file.VisualFile)
if !ok {
return nil, fmt.Errorf("file %T is not an image file", f)
return nil, fmt.Errorf("file %T is not an frame", f)
}
return ret, nil
return asFrame, nil
}
return nil, nil
}
func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]*file.ImageFile, error) {
func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]file.File, error) {
fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID)
if err != nil {
return nil, err
}
files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs)
ret := make([]*file.ImageFile, len(files))
for i, bf := range files {
f, ok := bf.(*file.ImageFile)
if !ok {
return nil, fmt.Errorf("file %T is not an image file", f)
}
ret[i] = f
}
return ret, firstError(errs)
return files, firstError(errs)
}
func (r *imageResolver) Title(ctx context.Context, obj *models.Image) (*string, error) {
@@ -65,9 +78,9 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile
return nil, nil
}
width := f.Width
height := f.Height
size := f.Size
width := f.GetWidth()
height := f.GetHeight()
size := f.Base().Size
return &ImageFileType{
Size: int(size),
Width: width,
@@ -75,6 +88,32 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile
}, nil
}
func convertVisualFile(f file.File) VisualFile {
switch f := f.(type) {
case *file.ImageFile:
return convertImageFile(f)
case *file.VideoFile:
return convertVideoFile(f)
default:
panic(fmt.Sprintf("unknown file type %T", f))
}
}
func (r *imageResolver) VisualFiles(ctx context.Context, obj *models.Image) ([]VisualFile, error) {
fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID)
if err != nil {
return nil, err
}
files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs)
ret := make([]VisualFile, len(files))
for i, f := range files {
ret[i] = convertVisualFile(f)
}
return ret, firstError(errs)
}
func (r *imageResolver) Date(ctx context.Context, obj *models.Image) (*string, error) {
if obj.Date != nil {
result := obj.Date.String()
@@ -89,27 +128,18 @@ func (r *imageResolver) Files(ctx context.Context, obj *models.Image) ([]*ImageF
return nil, err
}
ret := make([]*ImageFile, len(files))
var ret []*ImageFile
for i, f := range files {
ret[i] = &ImageFile{
ID: strconv.Itoa(int(f.ID)),
Path: f.Path,
Basename: f.Basename,
ParentFolderID: strconv.Itoa(int(f.ParentFolderID)),
ModTime: f.ModTime,
Size: f.Size,
Width: f.Width,
Height: f.Height,
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
Fingerprints: resolveFingerprints(f.Base()),
for _, f := range files {
// filter out non-image files
imageFile, ok := f.(*file.ImageFile)
if !ok {
continue
}
if f.ZipFileID != nil {
zipFileID := strconv.Itoa(int(*f.ZipFileID))
ret[i].ZipFileID = &zipFileID
}
thisFile := convertImageFile(imageFile)
ret = append(ret, thisFile)
}
return ret, nil
@@ -121,7 +151,7 @@ func (r *imageResolver) FileModTime(ctx context.Context, obj *models.Image) (*ti
return nil, err
}
if f != nil {
return &f.ModTime, nil
return &f.Base().ModTime, nil
}
return nil, nil
@@ -131,10 +161,12 @@ func (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*ImagePat
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewImageURLBuilder(baseURL, obj)
thumbnailPath := builder.GetThumbnailURL()
previewPath := builder.GetPreviewURL()
imagePath := builder.GetImageURL()
return &ImagePathsType{
Image: &imagePath,
Thumbnail: &thumbnailPath,
Preview: &previewPath,
}, nil
}

View File

@@ -14,6 +14,35 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
func convertVideoFile(f *file.VideoFile) *VideoFile {
ret := &VideoFile{
ID: strconv.Itoa(int(f.ID)),
Path: f.Path,
Basename: f.Basename,
ParentFolderID: strconv.Itoa(int(f.ParentFolderID)),
ModTime: f.ModTime,
Format: f.Format,
Size: f.Size,
Duration: handleFloat64Value(f.Duration),
VideoCodec: f.VideoCodec,
AudioCodec: f.AudioCodec,
Width: f.Width,
Height: f.Height,
FrameRate: handleFloat64Value(f.FrameRate),
BitRate: int(f.BitRate),
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
Fingerprints: resolveFingerprints(f.Base()),
}
if f.ZipFileID != nil {
zipFileID := strconv.Itoa(int(*f.ZipFileID))
ret.ZipFileID = &zipFileID
}
return ret
}
func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (*file.VideoFile, error) {
if obj.PrimaryFileID != nil {
f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID)
@@ -112,30 +141,7 @@ func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoF
ret := make([]*VideoFile, len(files))
for i, f := range files {
ret[i] = &VideoFile{
ID: strconv.Itoa(int(f.ID)),
Path: f.Path,
Basename: f.Basename,
ParentFolderID: strconv.Itoa(int(f.ParentFolderID)),
ModTime: f.ModTime,
Format: f.Format,
Size: f.Size,
Duration: handleFloat64Value(f.Duration),
VideoCodec: f.VideoCodec,
AudioCodec: f.AudioCodec,
Width: f.Width,
Height: f.Height,
FrameRate: handleFloat64Value(f.FrameRate),
BitRate: int(f.BitRate),
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
Fingerprints: resolveFingerprints(f.Base()),
}
if f.ZipFileID != nil {
zipFileID := strconv.Itoa(int(*f.ZipFileID))
ret[i].ZipFileID = &zipFileID
}
ret[i] = convertVideoFile(f)
}
return ret, nil

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ func (rs imageRoutes) Routes() chi.Router {
r.Get("/image", rs.Image)
r.Get("/thumbnail", rs.Thumbnail)
r.Get("/preview", rs.Preview)
})
return r
@@ -64,13 +65,19 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
return
}
encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG)
clipPreviewOptions := image.ClipPreviewOptions{
InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(),
OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(),
Preset: manager.GetInstance().Config.GetPreviewPreset().String(),
}
encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG, manager.GetInstance().FFProbe, clipPreviewOptions)
data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)
if err != nil {
// don't log for unsupported image format
// don't log for file not found - can optionally be logged in serveImage
if !errors.Is(err, image.ErrNotSupportedForThumbnail) && !errors.Is(err, fs.ErrNotExist) {
logger.Errorf("error generating thumbnail for %s: %v", f.Path, err)
logger.Errorf("error generating thumbnail for %s: %v", f.Base().Path, err)
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
@@ -96,6 +103,14 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
}
}
func (rs imageRoutes) Preview(w http.ResponseWriter, r *http.Request) {
img := r.Context().Value(imageKey).(*models.Image)
filepath := manager.GetInstance().Paths.Generated.GetClipPreviewPath(img.Checksum, models.DefaultGthumbWidth)
// don't check if the preview exists - we'll just return a 404 if it doesn't
utils.ServeStaticFile(w, r, filepath)
}
func (rs imageRoutes) Image(w http.ResponseWriter, r *http.Request) {
i := r.Context().Value(imageKey).(*models.Image)
@@ -107,7 +122,7 @@ func (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *mode
const defaultImageImage = "image/image.svg"
if i.Files.Primary() != nil {
err := i.Files.Primary().Serve(&file.OsFS{}, w, r)
err := i.Files.Primary().Base().Serve(&file.OsFS{}, w, r)
if err == nil {
return
}

View File

@@ -3,12 +3,15 @@ package urlbuilders
import (
"strconv"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/models"
)
type ImageURLBuilder struct {
BaseURL string
ImageID string
Checksum string
UpdatedAt string
}
@@ -16,6 +19,7 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder {
return ImageURLBuilder{
BaseURL: baseURL,
ImageID: strconv.Itoa(image.ID),
Checksum: image.Checksum,
UpdatedAt: strconv.FormatInt(image.UpdatedAt.Unix(), 10),
}
}
@@ -27,3 +31,11 @@ func (b ImageURLBuilder) GetImageURL() string {
func (b ImageURLBuilder) GetThumbnailURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt
}
func (b ImageURLBuilder) GetPreviewURL() string {
if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil {
return b.BaseURL + "/image/" + b.ImageID + "/preview?" + b.UpdatedAt
} else {
return ""
}
}

View File

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

View File

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

View File

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

View File

@@ -279,11 +279,11 @@ func initialize() error {
}
func videoFileFilter(ctx context.Context, f file.File) bool {
return isVideo(f.Base().Basename)
return useAsVideo(f.Base().Path)
}
func imageFileFilter(ctx context.Context, f file.File) bool {
return isImage(f.Base().Basename)
return useAsImage(f.Base().Path)
}
func galleryFileFilter(ctx context.Context, f file.File) bool {
@@ -306,7 +306,9 @@ func makeScanner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Scanner {
Filter: file.FilterFunc(videoFileFilter),
},
&file.FilteredDecorator{
Decorator: &file_image.Decorator{},
Decorator: &file_image.Decorator{
FFProbe: instance.FFProbe,
},
Filter: file.FilterFunc(imageFileFilter),
},
},

View File

@@ -15,6 +15,20 @@ import (
"github.com/stashapp/stash/pkg/models"
)
func useAsVideo(pathname string) bool {
if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo {
return false
}
return isVideo(pathname)
}
func useAsImage(pathname string) bool {
if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo {
return isImage(pathname) || isVideo(pathname)
}
return isImage(pathname)
}
func isZip(pathname string) bool {
gExt := config.GetInstance().GetGalleryExtensions()
return fsutil.MatchExtension(pathname, gExt)

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import (
"github.com/remeh/sizedwaitgroup"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@@ -29,6 +30,7 @@ type GenerateMetadataInput struct {
ForceTranscodes bool `json:"forceTranscodes"`
Phashes bool `json:"phashes"`
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
ClipPreviews bool `json:"clipPreviews"`
// scene ids to generate for
SceneIDs []string `json:"sceneIDs"`
// marker ids to generate for
@@ -69,6 +71,7 @@ type totalsGenerate struct {
transcodes int64
phashes int64
interactiveHeatmapSpeeds int64
clipPreviews int64
tasks int
}
@@ -167,6 +170,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
if j.input.InteractiveHeatmapsSpeeds {
logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds)
}
if j.input.ClipPreviews {
logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews)
}
if logMsg == "Generating" {
logMsg = "Nothing selected to generate"
}
@@ -254,6 +260,38 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que
}
}
*findFilter.Page = 1
for more := j.input.ClipPreviews; more; {
if job.IsCancelled(ctx) {
return totals
}
images, err := image.Query(ctx, j.txnManager.Image, nil, findFilter)
if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return totals
}
for _, ss := range images {
if job.IsCancelled(ctx) {
return totals
}
if err := ss.LoadFiles(ctx, j.txnManager.Image); err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return totals
}
j.queueImageJob(g, ss, queue, &totals)
}
if len(images) != batchSize {
more = false
} else {
*findFilter.Page++
}
}
return totals
}
@@ -434,3 +472,16 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene
totals.tasks++
queue <- task
}
func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task, totals *totalsGenerate) {
task := &GenerateClipPreviewTask{
Image: *image,
Overwrite: j.overwrite,
}
if task.required() {
totals.clipPreviews++
totals.tasks++
queue <- task
}
}

View File

@@ -0,0 +1,68 @@
package manager
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
type GenerateClipPreviewTask struct {
Image models.Image
Overwrite bool
}
func (t *GenerateClipPreviewTask) GetDescription() string {
return fmt.Sprintf("Generating Preview for image Clip %s", t.Image.Path)
}
func (t *GenerateClipPreviewTask) Start(ctx context.Context) {
if !t.required() {
return
}
prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth)
filePath := t.Image.Files.Primary().Base().Path
clipPreviewOptions := image.ClipPreviewOptions{
InputArgs: GetInstance().Config.GetTranscodeInputArgs(),
OutputArgs: GetInstance().Config.GetTranscodeOutputArgs(),
Preset: GetInstance().Config.GetPreviewPreset().String(),
}
encoder := image.NewThumbnailEncoder(GetInstance().FFMPEG, GetInstance().FFProbe, clipPreviewOptions)
data, err := encoder.GetPreview(t.Image.Files.Primary(), models.DefaultGthumbWidth)
if err != nil {
logger.Errorf("getting preview for image %s: %w", filePath, err)
return
}
err = fsutil.WriteFile(prevPath, data)
if err != nil {
logger.Errorf("writing preview for image %s: %w", filePath, err)
return
}
}
func (t *GenerateClipPreviewTask) required() bool {
_, ok := t.Image.Files.Primary().(*file.VideoFile)
if !ok {
return false
}
if t.Overwrite {
return true
}
prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth)
if exists, _ := fsutil.FileExists(prevPath); exists {
return false
}
return true
}

View File

@@ -141,8 +141,8 @@ func newHandlerRequiredFilter(c *config.Instance) *handlerRequiredFilter {
func (f *handlerRequiredFilter) Accept(ctx context.Context, ff file.File) bool {
path := ff.Base().Path
isVideoFile := fsutil.MatchExtension(path, f.vidExt)
isImageFile := fsutil.MatchExtension(path, f.imgExt)
isVideoFile := useAsVideo(path)
isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt)
var counter fileCounter
@@ -255,8 +255,8 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
return false
}
isVideoFile := fsutil.MatchExtension(path, f.vidExt)
isImageFile := fsutil.MatchExtension(path, f.imgExt)
isVideoFile := useAsVideo(path)
isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt)
// handle caption files
@@ -289,7 +289,7 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
// shortcut: skip the directory entirely if it matches both exclusion patterns
// add a trailing separator so that it correctly matches against patterns like path/.*
pathExcludeTest := path + string(filepath.Separator)
if (s.ExcludeVideo || matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) {
if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) {
logger.Debugf("Skipping directory %s as it matches video and image exclusion patterns", path)
return false
}
@@ -307,16 +307,13 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
type scanConfig struct {
isGenerateThumbnails bool
isGenerateClipPreviews bool
}
func (c *scanConfig) GetCreateGalleriesFromFolders() bool {
return instance.Config.GetCreateGalleriesFromFolders()
}
func (c *scanConfig) IsGenerateThumbnails() bool {
return c.isGenerateThumbnails
}
func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler {
db := instance.Database
pluginCache := instance.PluginCache
@@ -327,9 +324,14 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
Handler: &image.ScanHandler{
CreatorUpdater: db.Image,
GalleryFinder: db.Gallery,
ThumbnailGenerator: &imageThumbnailGenerator{},
ScanGenerator: &imageGenerators{
input: options,
taskQueue: taskQueue,
progress: progress,
},
ScanConfig: &scanConfig{
isGenerateThumbnails: options.ScanGenerateThumbnails,
isGenerateClipPreviews: options.ScanGenerateClipPreviews,
},
PluginCache: pluginCache,
Paths: instance.Paths,
@@ -362,35 +364,97 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
}
}
type imageThumbnailGenerator struct{}
type imageGenerators struct {
input ScanMetadataInput
taskQueue *job.TaskQueue
progress *job.Progress
}
func (g *imageThumbnailGenerator) GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error {
func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f file.File) error {
const overwrite = false
progress := g.progress
t := g.input
path := f.Base().Path
config := instance.Config
sequentialScanning := config.GetSequentialScanning()
if t.ScanGenerateThumbnails {
// this should be quick, so always generate sequentially
if err := g.generateThumbnail(ctx, i, f); err != nil {
logger.Errorf("Error generating thumbnail for %s: %v", path, err)
}
}
// avoid adding a task if the file isn't a video file
_, isVideo := f.(*file.VideoFile)
if isVideo && t.ScanGenerateClipPreviews {
// this is a bit of a hack: the task requires files to be loaded, but
// we don't really need to since we already have the file
ii := *i
ii.Files = models.NewRelatedFiles([]file.File{f})
progress.AddTotal(1)
previewsFn := func(ctx context.Context) {
taskPreview := GenerateClipPreviewTask{
Image: ii,
Overwrite: overwrite,
}
taskPreview.Start(ctx)
progress.Increment()
}
if sequentialScanning {
previewsFn(ctx)
} else {
g.taskQueue.Add(fmt.Sprintf("Generating preview for %s", path), previewsFn)
}
}
return nil
}
func (g *imageGenerators) generateThumbnail(ctx context.Context, i *models.Image, f file.File) error {
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth)
exists, _ := fsutil.FileExists(thumbPath)
if exists {
return nil
}
if f.Height <= models.DefaultGthumbWidth && f.Width <= models.DefaultGthumbWidth {
path := f.Base().Path
asFrame, ok := f.(file.VisualFile)
if !ok {
return fmt.Errorf("file %s does not implement Frame", path)
}
if asFrame.GetHeight() <= models.DefaultGthumbWidth && asFrame.GetWidth() <= models.DefaultGthumbWidth {
return nil
}
logger.Debugf("Generating thumbnail for %s", f.Path)
logger.Debugf("Generating thumbnail for %s", path)
encoder := image.NewThumbnailEncoder(instance.FFMPEG)
clipPreviewOptions := image.ClipPreviewOptions{
InputArgs: instance.Config.GetTranscodeInputArgs(),
OutputArgs: instance.Config.GetTranscodeOutputArgs(),
Preset: instance.Config.GetPreviewPreset().String(),
}
encoder := image.NewThumbnailEncoder(instance.FFMPEG, instance.FFProbe, clipPreviewOptions)
data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)
if err != nil {
// don't log for animated images
if !errors.Is(err, image.ErrNotSupportedForThumbnail) {
return fmt.Errorf("getting thumbnail for image %s: %w", f.Path, err)
return fmt.Errorf("getting thumbnail for image %s: %w", path, err)
}
return nil
}
err = fsutil.WriteFile(thumbPath, data)
if err != nil {
return fmt.Errorf("writing thumbnail for image %s: %w", f.Path, err)
return fmt.Errorf("writing thumbnail for image %s: %w", path, err)
}
return nil

View File

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

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

@@ -0,0 +1,20 @@
package file
// VisualFile is an interface for files that have a width and height.
type VisualFile interface {
File
GetWidth() int
GetHeight() int
GetFormat() string
}
func GetMinResolution(f VisualFile) int {
w := f.GetWidth()
h := f.GetHeight()
if w < h {
return w
}
return h
}

View File

@@ -9,12 +9,15 @@ import (
_ "image/jpeg"
_ "image/png"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/file/video"
_ "golang.org/x/image/webp"
)
// Decorator adds image specific fields to a File.
type Decorator struct {
FFProbe ffmpeg.FFProbe
}
func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file.File, error) {
@@ -25,11 +28,13 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file
}
defer r.Close()
probe, err := d.FFProbe.NewVideoFile(base.Path)
if err != nil {
fmt.Printf("Warning: File %q could not be read with ffprobe: %s, assuming ImageFile", base.Path, err)
c, format, err := image.DecodeConfig(r)
if err != nil {
return f, fmt.Errorf("decoding image file %q: %w", base.Path, err)
}
return &file.ImageFile{
BaseFile: base,
Format: format,
@@ -38,6 +43,26 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file
}, nil
}
isClip := true
// This list is derived from ffmpegImageThumbnail in pkg/image/thumbnail. If one gets updated, the other should be as well
for _, item := range []string{"png", "mjpeg", "webp"} {
if item == probe.VideoCodec {
isClip = false
}
}
if isClip {
videoFileDecorator := video.Decorator{FFProbe: d.FFProbe}
return videoFileDecorator.Decorate(ctx, fs, f)
}
return &file.ImageFile{
BaseFile: base,
Format: probe.VideoCodec,
Width: probe.Width,
Height: probe.Height,
}, nil
}
func (d *Decorator) IsMissingMetadata(ctx context.Context, fs file.FS, f file.File) bool {
const (
unsetString = "unset"

View File

@@ -7,3 +7,15 @@ type ImageFile struct {
Width int `json:"width"`
Height int `json:"height"`
}
func (f ImageFile) GetWidth() int {
return f.Width
}
func (f ImageFile) GetHeight() int {
return f.Height
}
func (f ImageFile) GetFormat() string {
return f.Format
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ import (
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/ffmpeg/transcoder"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/models"
)
const ffmpegImageQuality = 5
@@ -27,13 +26,17 @@ var (
ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail")
)
type ThumbnailGenerator interface {
GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error
type ThumbnailEncoder struct {
FFMpeg *ffmpeg.FFMpeg
FFProbe ffmpeg.FFProbe
ClipPreviewOptions ClipPreviewOptions
vips *vipsEncoder
}
type ThumbnailEncoder struct {
ffmpeg *ffmpeg.FFMpeg
vips *vipsEncoder
type ClipPreviewOptions struct {
InputArgs []string
OutputArgs []string
Preset string
}
func GetVipsPath() string {
@@ -43,9 +46,11 @@ func GetVipsPath() string {
return vipsPath
}
func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder {
func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder {
ret := ThumbnailEncoder{
ffmpeg: ffmpegEncoder,
FFMpeg: ffmpegEncoder,
FFProbe: ffProbe,
ClipPreviewOptions: clipPreviewOptions,
}
vipsPath := GetVipsPath()
@@ -61,7 +66,7 @@ func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder {
// the provided max size. It resizes based on the largest X/Y direction.
// It returns nil and an error if an error occurs reading, decoding or encoding
// the image, or if the image is not suitable for thumbnails.
func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte, error) {
func (e *ThumbnailEncoder) GetThumbnail(f file.File, maxSize int) ([]byte, error) {
reader, err := f.Open(&file.OsFS{})
if err != nil {
return nil, err
@@ -75,8 +80,9 @@ func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte,
data := buf.Bytes()
format := f.Format
animated := f.Format == formatGif
if imageFile, ok := f.(*file.ImageFile); ok {
format := imageFile.Format
animated := imageFile.Format == formatGif
// #2266 - if image is webp, then determine if it is animated
if format == formatWebP {
@@ -87,35 +93,100 @@ func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte,
if animated {
return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format)
}
}
// Videofiles can only be thumbnailed with ffmpeg
if _, ok := f.(*file.VideoFile); ok {
return e.ffmpegImageThumbnail(buf, maxSize)
}
// vips has issues loading files from stdin on Windows
if e.vips != nil && runtime.GOOS != "windows" {
return e.vips.ImageThumbnail(buf, maxSize)
} else {
return e.ffmpegImageThumbnail(buf, format, maxSize)
return e.ffmpegImageThumbnail(buf, maxSize)
}
}
func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, format string, maxSize int) ([]byte, error) {
var ffmpegFormat ffmpeg.ImageFormat
// GetPreview returns the preview clip of the provided image clip resized to
// the provided max size. It resizes based on the largest X/Y direction.
// It returns nil and an error if an error occurs reading, decoding or encoding
// the image, or if the image is not suitable for thumbnails.
// It is hardcoded to 30 seconds maximum right now
func (e *ThumbnailEncoder) GetPreview(f file.File, maxSize int) ([]byte, error) {
reader, err := f.Open(&file.OsFS{})
if err != nil {
return nil, err
}
defer reader.Close()
switch format {
case "jpeg":
ffmpegFormat = ffmpeg.ImageFormatJpeg
case "png":
ffmpegFormat = ffmpeg.ImageFormatPng
case "webp":
ffmpegFormat = ffmpeg.ImageFormatWebp
default:
return nil, ErrUnsupportedImageFormat
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(reader); err != nil {
return nil, err
}
fileData, err := e.FFProbe.NewVideoFile(f.Base().Path)
if err != nil {
return nil, err
}
if fileData.Width <= maxSize {
maxSize = fileData.Width
}
clipDuration := fileData.VideoStreamDuration
if clipDuration > 30.0 {
clipDuration = 30.0
}
return e.getClipPreview(buf, maxSize, clipDuration, fileData.FrameRate)
}
func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) {
args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{
InputFormat: ffmpegFormat,
OutputFormat: ffmpeg.ImageFormatJpeg,
OutputPath: "-",
MaxDimensions: maxSize,
Quality: ffmpegImageQuality,
})
return e.ffmpeg.GenerateOutput(context.TODO(), args, image)
return e.FFMpeg.GenerateOutput(context.TODO(), args, image)
}
func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clipDuration float64, frameRate float64) ([]byte, error) {
var thumbFilter ffmpeg.VideoFilter
thumbFilter = thumbFilter.ScaleMaxSize(maxSize)
var thumbArgs ffmpeg.Args
thumbArgs = thumbArgs.VideoFilter(thumbFilter)
o := e.ClipPreviewOptions
thumbArgs = append(thumbArgs,
"-pix_fmt", "yuv420p",
"-preset", o.Preset,
"-crf", "25",
"-threads", "4",
"-strict", "-2",
"-f", "webm",
)
if frameRate <= 0.01 {
thumbArgs = append(thumbArgs, "-vsync", "2")
}
thumbOptions := transcoder.TranscodeOptions{
OutputPath: "-",
StartTime: 0,
Duration: clipDuration,
XError: true,
SlowSeek: false,
VideoCodec: ffmpeg.VideoCodecVP9,
VideoArgs: thumbArgs,
ExtraInputArgs: o.InputArgs,
ExtraOutputArgs: o.OutputArgs,
}
args := transcoder.Transcode("-", thumbOptions)
return e.FFMpeg.GenerateOutput(context.TODO(), args, image)
}

View File

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

View File

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

View File

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

View File

@@ -34,10 +34,6 @@ type VideoFileLoader interface {
GetFiles(ctx context.Context, relatedID int) ([]*file.VideoFile, error)
}
type ImageFileLoader interface {
GetFiles(ctx context.Context, relatedID int) ([]*file.ImageFile, error)
}
type FileLoader interface {
GetFiles(ctx context.Context, relatedID int) ([]file.File, error)
}
@@ -320,89 +316,6 @@ func (r *RelatedVideoFiles) loadPrimary(fn func() (*file.VideoFile, error)) erro
return nil
}
type RelatedImageFiles struct {
primaryFile *file.ImageFile
files []*file.ImageFile
primaryLoaded bool
}
func NewRelatedImageFiles(files []*file.ImageFile) RelatedImageFiles {
ret := RelatedImageFiles{
files: files,
primaryLoaded: true,
}
if len(files) > 0 {
ret.primaryFile = files[0]
}
return ret
}
// Loaded returns true if the relationship has been loaded.
func (r RelatedImageFiles) Loaded() bool {
return r.files != nil
}
// Loaded returns true if the primary file relationship has been loaded.
func (r RelatedImageFiles) PrimaryLoaded() bool {
return r.primaryLoaded
}
// List returns the related files. Panics if the relationship has not been loaded.
func (r RelatedImageFiles) List() []*file.ImageFile {
if !r.Loaded() {
panic("relationship has not been loaded")
}
return r.files
}
// Primary returns the primary file. Panics if the relationship has not been loaded.
func (r RelatedImageFiles) Primary() *file.ImageFile {
if !r.PrimaryLoaded() {
panic("relationship has not been loaded")
}
return r.primaryFile
}
func (r *RelatedImageFiles) load(fn func() ([]*file.ImageFile, error)) error {
if r.Loaded() {
return nil
}
var err error
r.files, err = fn()
if err != nil {
return err
}
if len(r.files) > 0 {
r.primaryFile = r.files[0]
}
r.primaryLoaded = true
return nil
}
func (r *RelatedImageFiles) loadPrimary(fn func() (*file.ImageFile, error)) error {
if r.PrimaryLoaded() {
return nil
}
var err error
r.primaryFile, err = fn()
if err != nil {
return err
}
r.primaryLoaded = true
return nil
}
type RelatedFiles struct {
primaryFile file.File
files []file.File

View File

@@ -241,7 +241,7 @@ func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) e
if updatedObject.Files.Loaded() {
fileIDs := make([]file.ID, len(updatedObject.Files.List()))
for i, f := range updatedObject.Files.List() {
fileIDs[i] = f.ID
fileIDs[i] = f.Base().ID
}
if err := imagesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil {
@@ -360,7 +360,7 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo
return ret, nil
}
func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile, error) {
func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]file.File, error) {
fileIDs, err := qb.filesRepository().get(ctx, id)
if err != nil {
return nil, err
@@ -372,16 +372,7 @@ func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile,
return nil, err
}
ret := make([]*file.ImageFile, len(files))
for i, f := range files {
var ok bool
ret[i], ok = f.(*file.ImageFile)
if !ok {
return nil, fmt.Errorf("expected file to be *file.ImageFile not %T", f)
}
}
return ret, nil
return files, nil
}
func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -130,6 +130,14 @@ export const SettingsLibraryPanel: React.FC = () => {
onChange={(v) => saveGeneral({ writeImageThumbnails: v })}
/>
<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
id="gallery-cover-regex"
headingID="config.general.gallery_cover_regex_label"

View File

@@ -142,6 +142,12 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
headingID="dialogs.scene_gen.interactive_heatmap_speed"
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
id="overwrite"
checked={options.overwrite ?? false}

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
# Galleries
**Note:** images are now included during the scan process and are loaded independently of galleries. It is _no longer necessary_ to have images in zip files to be scanned into your library.
Galleries are automatically created from zip files found during scanning that contain images. It is also possible to automatically create galleries from folders containing images, by selecting the "Create galleries from folders containing images" checkbox in the Configuration page. It is also possible to manually create galleries.
For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance.
If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected.
Images can be added to a gallery by navigating to the gallery's page, selecting the "Add" tab, querying for and selecting the images to add, then selecting "Add to Gallery" from the `...` menu button. Likewise, images may be removed from a gallery by selecting the "Images" tab, selecting the images to remove and selecting "Remove from Gallery" from the `...` menu button.

View File

@@ -0,0 +1,27 @@
# Images and Galleries
Images are the parts which make up galleries, but you can also have them be scanned independently. To declare an image part of a gallery, there are four ways:
1. Group them in a folder together and activate the **Create galleries from folders containing images** option in the library section of your settings. The gallery will get the name of the folder.
2. Group them in a folder together and create a file in the folder called .forcegallery. The gallery will get the name of the folder.
3. Group them into a zip archive together. The gallery will get the name of the archive.
4. You can simply create a gallery in stash itself by clicking on **New** in the Galleries tab.
You can add images to every gallery manually in the gallery detail page. Deleting can be done by selecting the according images in the same view and clicking on the minus next to the edit button.
For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance.
If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected.
## Image clips/gifs
Images can also be clips/gifs. These are meant to be short video loops. Right now they are not possible in zipfiles. To declare video files to be images, there are two ways:
1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan Video Extensions as Image Clip** option in the library section of your settings.
2. Make sure none of the file endings used by your clips/gifs are present in the **Video Extensions** and add them to the **Image Extensions** in the library section of your settings.
A clip/gif will be a stillframe in the wall and grid view by default. To view the loop, you can go into the Lightbox Carousel (e.g. by clicking on an image in the wall view) or the image detail page.
If you want the loop to be used as a preview on the wall and grid view, you will have to generate them.
You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image Clip Previews** and clicking generate. This takes a while, as the files are transcoded.

View File

@@ -20,6 +20,7 @@ The scan task accepts the following options:
| Generate scrubber sprites | Generates sprites for the scene scrubber. |
| Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
| Generate thumbnails for images | Generates thumbnails for image files. |
| Generate previews for image clips | Generates a gif/looping video as thumbnail for image clips/gifs. |
# Auto Tagging
See the [Auto Tagging](/help/AutoTagging.md) page.
@@ -51,6 +52,7 @@ The generate task accepts the following options:
| Transcodes | MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. |
| Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. |
| Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. |
| Image Clip Previews | Generates a gif/looping video as thumbnail for image clips/gifs. |
| Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. |
## Transcodes

View File

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

View File

@@ -59,6 +59,7 @@ interface IProps {
setZoom: (v: number) => void;
onLeft: () => void;
onRight: () => void;
isVideo: boolean;
}
export const LightboxImage: React.FC<IProps> = ({
@@ -74,6 +75,7 @@ export const LightboxImage: React.FC<IProps> = ({
current,
setZoom,
resetPosition,
isVideo,
}) => {
const [defaultZoom, setDefaultZoom] = useState(1);
const [moving, setMoving] = useState(false);
@@ -89,7 +91,7 @@ export const LightboxImage: React.FC<IProps> = ({
const container = React.createRef<HTMLDivElement>();
const startPoints = useRef<number[]>([0, 0]);
const pointerCache = useRef<React.PointerEvent<HTMLDivElement>[]>([]);
const pointerCache = useRef<React.PointerEvent[]>([]);
const prevDiff = useRef<number | undefined>();
const scrollAttempts = useRef(0);
@@ -100,6 +102,24 @@ export const LightboxImage: React.FC<IProps> = ({
setBoxWidth(box.offsetWidth);
setBoxHeight(box.offsetHeight);
}
function toggleVideoPlay() {
if (container.current) {
let openVideo = container.current.getElementsByTagName("video");
if (openVideo.length > 0) {
let rect = openVideo[0].getBoundingClientRect();
if (Math.abs(rect.x) < document.body.clientWidth / 2) {
openVideo[0].play();
} else {
openVideo[0].pause();
}
}
}
}
setTimeout(() => {
toggleVideoPlay();
}, 250);
}, [container]);
useEffect(() => {
@@ -233,7 +253,12 @@ export const LightboxImage: React.FC<IProps> = ({
calculateInitialPosition,
]);
function getScrollMode(ev: React.WheelEvent<HTMLDivElement>) {
function getScrollMode(
ev:
| React.WheelEvent<HTMLImageElement>
| React.WheelEvent<HTMLVideoElement>
| React.WheelEvent<HTMLDivElement>
) {
if (ev.shiftKey) {
switch (scrollMode) {
case GQL.ImageLightboxScrollMode.Zoom:
@@ -246,14 +271,24 @@ export const LightboxImage: React.FC<IProps> = ({
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
if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) {
onImageScroll(ev);
}
}
function onImageScrollPanY(ev: React.WheelEvent<HTMLDivElement>) {
function onImageScrollPanY(
ev:
| React.WheelEvent<HTMLImageElement>
| React.WheelEvent<HTMLVideoElement>
| React.WheelEvent<HTMLDivElement>
) {
if (current) {
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;
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 (!ev.buttons) {
@@ -327,14 +371,22 @@ export const LightboxImage: React.FC<IProps> = ({
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];
setMoving(true);
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 (
@@ -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();
if (ev.touches.length === 1) {
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 (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
pointerCache.current = pointerCache.current.filter(
(e) => e.pointerId !== ev.pointerId
@@ -391,7 +458,12 @@ export const LightboxImage: React.FC<IProps> = ({
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++) {
if (pointerCache.current[i].pointerId === ev.pointerId) {
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
const cachedIndex = pointerCache.current.findIndex(
(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 (
<div
ref={container}
@@ -448,11 +536,12 @@ export const LightboxImage: React.FC<IProps> = ({
>
<source srcSet={src} media="(min-width: 800px)" />
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<img
<ImageView
loop={isVideo}
src={src}
alt=""
draggable={false}
style={{ touchAction: "none" }}
style={customStyle}
onWheel={current ? (e) => onImageScroll(e) : undefined}
onMouseDown={(e) => onImageMouseDown(e)}
onMouseUp={(e) => onImageMouseUp(e)}

View File

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

View File

@@ -422,6 +422,7 @@
"generating_from_paths": "Generating for scenes from the following paths",
"generating_scenes": "Generating for {num} {scene}"
},
"generate_clip_previews_during_scan": "Generate previews for image clips",
"generate_desc": "Generate supporting image, sprite, video, vtt and other files.",
"generate_phashes_during_scan": "Generate perceptual hashes",
"generate_phashes_during_scan_tooltip": "For deduplication and scene identification.",
@@ -592,6 +593,10 @@
"write_image_thumbnails": {
"description": "Write image thumbnails to disk when generated on-the-fly",
"heading": "Write image thumbnails"
},
"create_image_clips_from_videos": {
"description": "When a library has Videos disabled, Video Files (files ending with Video Extension) will be scanned as Image Clip",
"heading": "Scan Video Extensions as Image Clip"
}
}
},
@@ -799,6 +804,7 @@
"destination": "Reassign to"
},
"scene_gen": {
"clip_previews": "Image Clip Previews",
"covers": "Scene covers",
"force_transcodes": "Force Transcode generation",
"force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.",