mirror of
https://github.com/stashapp/stash.git
synced 2025-12-15 21:03:22 +01:00
Improve gallery performance (#3183)
* Improve cover resolver performance * Deprecate and remove usage of slow gallery images
This commit is contained in:
parent
f0a3a3dd44
commit
57ad12e43b
10 changed files with 188 additions and 97 deletions
|
|
@ -16,9 +16,6 @@ fragment GalleryData on Gallery {
|
|||
...FolderData
|
||||
}
|
||||
|
||||
images {
|
||||
...SlimImageData
|
||||
}
|
||||
cover {
|
||||
...SlimImageData
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ type Gallery {
|
|||
performers: [Performer!]!
|
||||
|
||||
"""The images in the gallery"""
|
||||
images: [Image!]! # Resolver
|
||||
images: [Image!]! @deprecated(reason: "Use findImages")
|
||||
cover: Image
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ func (r *galleryResolver) FileModTime(ctx context.Context, obj *models.Gallery)
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
// Images is deprecated, slow and shouldn't be used
|
||||
func (r *galleryResolver) Images(ctx context.Context, obj *models.Gallery) (ret []*models.Image, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
|
|
@ -144,24 +145,9 @@ func (r *galleryResolver) Images(ctx context.Context, obj *models.Gallery) (ret
|
|||
|
||||
func (r *galleryResolver) Cover(ctx context.Context, obj *models.Gallery) (ret *models.Image, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
// doing this via Query is really slow, so stick with FindByGalleryID
|
||||
imgs, err := r.repository.Image.FindByGalleryID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(imgs) > 0 {
|
||||
ret = imgs[0]
|
||||
}
|
||||
|
||||
for _, img := range imgs {
|
||||
if image.IsCover(img) {
|
||||
ret = img
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// find cover.jpg first
|
||||
ret, err = image.FindGalleryCover(ctx, r.repository.Image, obj.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
package image
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
func IsCover(img *models.Image) bool {
|
||||
return strings.HasSuffix(img.Path, "cover.jpg")
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
package image
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsCover(t *testing.T) {
|
||||
type test struct {
|
||||
fn string
|
||||
isCover bool
|
||||
}
|
||||
|
||||
tests := []test{
|
||||
{"cover.jpg", true},
|
||||
{"covernot.jpg", false},
|
||||
{"Cover.jpg", false},
|
||||
{fmt.Sprintf("subDir%scover.jpg", string(filepath.Separator)), true},
|
||||
{"endsWithcover.jpg", true},
|
||||
{"cover.png", false},
|
||||
}
|
||||
|
||||
assert := assert.New(t)
|
||||
for _, tc := range tests {
|
||||
img := &models.Image{
|
||||
Path: tc.fn,
|
||||
}
|
||||
assert.Equal(tc.isCover, IsCover(img), "expected: %t for %s", tc.isCover, tc.fn)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,11 @@ import (
|
|||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
const (
|
||||
coverFilename = "cover.jpg"
|
||||
coverFilenameSearchString = "%" + coverFilename
|
||||
)
|
||||
|
||||
type Queryer interface {
|
||||
Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error)
|
||||
}
|
||||
|
|
@ -96,3 +101,56 @@ func FindByGalleryID(ctx context.Context, r Queryer, galleryID int, sortBy strin
|
|||
},
|
||||
}, &findFilter)
|
||||
}
|
||||
|
||||
func FindGalleryCover(ctx context.Context, r Queryer, galleryID int) (*models.Image, error) {
|
||||
const useCoverJpg = true
|
||||
img, err := findGalleryCover(ctx, r, galleryID, useCoverJpg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if img != nil {
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// return the first image in the gallery
|
||||
return findGalleryCover(ctx, r, galleryID, !useCoverJpg)
|
||||
}
|
||||
|
||||
func findGalleryCover(ctx context.Context, r Queryer, galleryID int, useCoverJpg bool) (*models.Image, error) {
|
||||
// try to find cover.jpg in the gallery
|
||||
perPage := 1
|
||||
sortBy := "path"
|
||||
sortDir := models.SortDirectionEnumAsc
|
||||
|
||||
findFilter := models.FindFilterType{
|
||||
PerPage: &perPage,
|
||||
Sort: &sortBy,
|
||||
Direction: &sortDir,
|
||||
}
|
||||
|
||||
imageFilter := &models.ImageFilterType{
|
||||
Galleries: &models.MultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(galleryID)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
}
|
||||
|
||||
if useCoverJpg {
|
||||
imageFilter.Path = &models.StringCriterionInput{
|
||||
Value: coverFilenameSearchString,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}
|
||||
}
|
||||
|
||||
imgs, err := Query(ctx, r, imageFilter, &findFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(imgs) > 0 {
|
||||
return imgs[0], nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,49 @@
|
|||
import React from "react";
|
||||
import { useFindGallery } from "src/core/StashService";
|
||||
import React, { useMemo } from "react";
|
||||
import { useLightbox } from "src/hooks";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
import "flexbin/flexbin.css";
|
||||
import {
|
||||
CriterionModifier,
|
||||
useFindImagesQuery,
|
||||
} from "src/core/generated-graphql";
|
||||
|
||||
interface IProps {
|
||||
galleryId: string;
|
||||
}
|
||||
|
||||
export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
const { data, loading } = useFindGallery(galleryId);
|
||||
const images = data?.findGallery?.images ?? [];
|
||||
const showLightbox = useLightbox({ images, showNavigation: false });
|
||||
// TODO - add paging - don't load all images at once
|
||||
const pageSize = -1;
|
||||
|
||||
const currentFilter = useMemo(() => {
|
||||
return {
|
||||
per_page: pageSize,
|
||||
sort: "path",
|
||||
};
|
||||
}, [pageSize]);
|
||||
|
||||
const { data, loading } = useFindImagesQuery({
|
||||
variables: {
|
||||
filter: currentFilter,
|
||||
image_filter: {
|
||||
galleries: {
|
||||
modifier: CriterionModifier.Includes,
|
||||
value: [galleryId],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const images = useMemo(() => data?.findImages?.images ?? [], [data]);
|
||||
|
||||
const lightboxState = useMemo(() => {
|
||||
return {
|
||||
images,
|
||||
showNavigation: false,
|
||||
};
|
||||
}, [images]);
|
||||
|
||||
const showLightbox = useLightbox(lightboxState);
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
* Added tag description filter criterion. ([#3011](https://github.com/stashapp/stash/pull/3011))
|
||||
|
||||
### 🎨 Improvements
|
||||
* Improved performance viewing galleries with many images. ([#3183](https://github.com/stashapp/stash/pull/3183))
|
||||
* Generated heatmaps now only show ranges within the duration of the scene. ([#3182](https://github.com/stashapp/stash/pull/3182))
|
||||
* Added File Modification Time to File Info panels. ([#3054](https://github.com/stashapp/stash/pull/3054))
|
||||
* Added counter to File Info tabs for objects with multiple files. ([#3054](https://github.com/stashapp/stash/pull/3054))
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
const [isSwitchingPage, setIsSwitchingPage] = useState(true);
|
||||
const [isFullscreen, setFullscreen] = useState(false);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [imagesLoaded, setImagesLoaded] = useState(0);
|
||||
const [navOffset, setNavOffset] = useState<React.CSSProperties | undefined>();
|
||||
|
||||
const oldImages = useRef<ILightboxImage[]>([]);
|
||||
|
||||
|
|
@ -191,7 +193,6 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
if (images !== oldImages.current && isSwitchingPage) {
|
||||
oldImages.current = images;
|
||||
if (index === -1) setIndex(images.length - 1);
|
||||
setIsSwitchingPage(false);
|
||||
}
|
||||
|
|
@ -220,30 +221,33 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
}
|
||||
setResetPosition((r) => !r);
|
||||
|
||||
if (carouselRef.current)
|
||||
carouselRef.current.style.left = `${index * -100}vw`;
|
||||
if (indicatorRef.current)
|
||||
indicatorRef.current.innerHTML = `${index + 1} / ${images.length}`;
|
||||
oldIndex.current = index;
|
||||
}, [index, images.length, lightboxSettings?.resetZoomOnNav]);
|
||||
|
||||
const getNavOffset = useCallback(() => {
|
||||
if (images.length < 2) return;
|
||||
if (index === undefined || index === null) return;
|
||||
|
||||
if (navRef.current) {
|
||||
const currentThumb = navRef.current.children[index + 1];
|
||||
if (currentThumb instanceof HTMLImageElement) {
|
||||
const offset =
|
||||
-1 *
|
||||
(currentThumb.offsetLeft - document.documentElement.clientWidth / 2);
|
||||
navRef.current.style.left = `${offset}px`;
|
||||
|
||||
const previouslySelected = navRef.current.getElementsByClassName(
|
||||
CLASSNAME_NAVSELECTED
|
||||
)?.[0];
|
||||
if (previouslySelected)
|
||||
previouslySelected.className = CLASSNAME_NAVIMAGE;
|
||||
|
||||
currentThumb.className = `${CLASSNAME_NAVIMAGE} ${CLASSNAME_NAVSELECTED}`;
|
||||
return { left: `${offset}px` };
|
||||
}
|
||||
}
|
||||
}, [index, images.length]);
|
||||
|
||||
oldIndex.current = index;
|
||||
}, [index, images.length, lightboxSettings?.resetZoomOnNav]);
|
||||
useEffect(() => {
|
||||
// reset images loaded counter for new images
|
||||
setImagesLoaded(0);
|
||||
}, [images]);
|
||||
|
||||
useEffect(() => {
|
||||
setNavOffset(getNavOffset() ?? undefined);
|
||||
}, [getNavOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayMode !== oldDisplayMode.current) {
|
||||
|
|
@ -313,6 +317,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
if (pageCallback) {
|
||||
pageCallback(-1);
|
||||
setIndex(-1);
|
||||
oldImages.current = images;
|
||||
setIsSwitchingPage(true);
|
||||
} else setIndex(images.length - 1);
|
||||
} else setIndex((index ?? 0) - 1);
|
||||
|
|
@ -334,6 +339,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
// go to preview page, or loop back if no callback is set
|
||||
if (pageCallback) {
|
||||
pageCallback(1);
|
||||
oldImages.current = images;
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(0);
|
||||
} else setIndex(0);
|
||||
|
|
@ -396,6 +402,15 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
else document.exitFullscreen();
|
||||
}, [isFullscreen]);
|
||||
|
||||
function imageLoaded() {
|
||||
setImagesLoaded((loaded) => loaded + 1);
|
||||
|
||||
if (imagesLoaded === images.length - 1) {
|
||||
// all images are loaded - update the nav offset
|
||||
setNavOffset(getNavOffset() ?? undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const navItems = images.map((image, i) => (
|
||||
<img
|
||||
src={image.paths.thumbnail ?? ""}
|
||||
|
|
@ -407,6 +422,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
role="presentation"
|
||||
loading="lazy"
|
||||
key={image.paths.thumbnail}
|
||||
onLoad={imageLoaded}
|
||||
/>
|
||||
));
|
||||
|
||||
|
|
@ -763,7 +779,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
)}
|
||||
</div>
|
||||
{showNavigation && !isFullscreen && images.length > 1 && (
|
||||
<div className={CLASSNAME_NAV} ref={navRef}>
|
||||
<div className={CLASSNAME_NAV} style={navOffset} ref={navRef}>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setIndex(images.length - 1)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useContext, useEffect } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { LightboxContext, IState } from "./context";
|
||||
|
||||
|
|
@ -39,27 +39,74 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
|
|||
|
||||
export const useGalleryLightbox = (id: string) => {
|
||||
const { setLightboxState } = useContext(LightboxContext);
|
||||
const [fetchGallery, { data }] = GQL.useFindGalleryLazyQuery({
|
||||
variables: { id },
|
||||
|
||||
const pageSize = 40;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const currentFilter = useMemo(() => {
|
||||
return {
|
||||
page,
|
||||
per_page: pageSize,
|
||||
sort: "path",
|
||||
};
|
||||
}, [page]);
|
||||
|
||||
const [fetchGallery, { data }] = GQL.useFindImagesLazyQuery({
|
||||
variables: {
|
||||
filter: currentFilter,
|
||||
image_filter: {
|
||||
galleries: {
|
||||
modifier: GQL.CriterionModifier.Includes,
|
||||
value: [id],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pages = useMemo(() => {
|
||||
const totalCount = data?.findImages.count ?? 0;
|
||||
return Math.ceil(totalCount / pageSize);
|
||||
}, [data?.findImages.count]);
|
||||
|
||||
const handleLightBoxPage = useCallback(
|
||||
(direction: number) => {
|
||||
if (direction === -1) {
|
||||
if (page === 1) {
|
||||
setPage(pages);
|
||||
} else {
|
||||
setPage(page - 1);
|
||||
}
|
||||
} else if (direction === 1) {
|
||||
if (page === pages) {
|
||||
// return to the first page
|
||||
setPage(1);
|
||||
} else {
|
||||
setPage(page + 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
[page, pages]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data)
|
||||
setLightboxState({
|
||||
images: data.findGallery?.images ?? [],
|
||||
isLoading: false,
|
||||
isVisible: true,
|
||||
images: data.findImages?.images ?? [],
|
||||
pageCallback: pages > 1 ? handleLightBoxPage : undefined,
|
||||
pageHeader: `Page ${page} / ${pages}`,
|
||||
});
|
||||
}, [setLightboxState, data]);
|
||||
}, [setLightboxState, data, handleLightBoxPage, page, pages]);
|
||||
|
||||
const show = () => {
|
||||
if (data)
|
||||
setLightboxState({
|
||||
isLoading: false,
|
||||
isVisible: true,
|
||||
images: data.findGallery?.images ?? [],
|
||||
pageCallback: undefined,
|
||||
pageHeader: undefined,
|
||||
images: data.findImages?.images ?? [],
|
||||
pageCallback: pages > 1 ? handleLightBoxPage : undefined,
|
||||
pageHeader: `Page ${page} / ${pages}`,
|
||||
});
|
||||
else {
|
||||
setLightboxState({
|
||||
|
|
|
|||
Loading…
Reference in a new issue