Improve gallery performance (#3183)

* Improve cover resolver performance
* Deprecate and remove usage of slow gallery images
This commit is contained in:
WithoutPants 2022-11-25 11:14:50 +11:00 committed by GitHub
parent f0a3a3dd44
commit 57ad12e43b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 188 additions and 97 deletions

View file

@ -16,9 +16,6 @@ fragment GalleryData on Gallery {
...FolderData
}
images {
...SlimImageData
}
cover {
...SlimImageData
}

View file

@ -26,7 +26,7 @@ type Gallery {
performers: [Performer!]!
"""The images in the gallery"""
images: [Image!]! # Resolver
images: [Image!]! @deprecated(reason: "Use findImages")
cover: Image
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 />;

View file

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

View file

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

View file

@ -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({