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
10 changed files with 188 additions and 97 deletions

View File

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

View File

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

View File

@@ -123,6 +123,7 @@ func (r *galleryResolver) FileModTime(ctx context.Context, obj *models.Gallery)
return nil, nil 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) { 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 { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err 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) { 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 { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
// doing this via Query is really slow, so stick with FindByGalleryID // find cover.jpg first
imgs, err := r.repository.Image.FindByGalleryID(ctx, obj.ID) ret, err = image.FindGalleryCover(ctx, r.repository.Image, obj.ID)
if err != nil { return err
return err
}
if len(imgs) > 0 {
ret = imgs[0]
}
for _, img := range imgs {
if image.IsCover(img) {
ret = img
break
}
}
return nil
}); err != nil { }); err != nil {
return nil, err 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" "github.com/stashapp/stash/pkg/models"
) )
const (
coverFilename = "cover.jpg"
coverFilenameSearchString = "%" + coverFilename
)
type Queryer interface { type Queryer interface {
Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) 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) }, &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 React, { useMemo } from "react";
import { useFindGallery } from "src/core/StashService";
import { useLightbox } from "src/hooks"; import { useLightbox } from "src/hooks";
import { LoadingIndicator } from "src/components/Shared"; import { LoadingIndicator } from "src/components/Shared";
import "flexbin/flexbin.css"; import "flexbin/flexbin.css";
import {
CriterionModifier,
useFindImagesQuery,
} from "src/core/generated-graphql";
interface IProps { interface IProps {
galleryId: string; galleryId: string;
} }
export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => { export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
const { data, loading } = useFindGallery(galleryId); // TODO - add paging - don't load all images at once
const images = data?.findGallery?.images ?? []; const pageSize = -1;
const showLightbox = useLightbox({ images, showNavigation: false });
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 />; if (loading) return <LoadingIndicator />;

View File

@@ -13,6 +13,7 @@
* Added tag description filter criterion. ([#3011](https://github.com/stashapp/stash/pull/3011)) * Added tag description filter criterion. ([#3011](https://github.com/stashapp/stash/pull/3011))
### 🎨 Improvements ### 🎨 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)) * 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 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)) * 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 [isSwitchingPage, setIsSwitchingPage] = useState(true);
const [isFullscreen, setFullscreen] = useState(false); const [isFullscreen, setFullscreen] = useState(false);
const [showOptions, setShowOptions] = useState(false); const [showOptions, setShowOptions] = useState(false);
const [imagesLoaded, setImagesLoaded] = useState(0);
const [navOffset, setNavOffset] = useState<React.CSSProperties | undefined>();
const oldImages = useRef<ILightboxImage[]>([]); const oldImages = useRef<ILightboxImage[]>([]);
@@ -191,7 +193,6 @@ export const LightboxComponent: React.FC<IProps> = ({
useEffect(() => { useEffect(() => {
if (images !== oldImages.current && isSwitchingPage) { if (images !== oldImages.current && isSwitchingPage) {
oldImages.current = images;
if (index === -1) setIndex(images.length - 1); if (index === -1) setIndex(images.length - 1);
setIsSwitchingPage(false); setIsSwitchingPage(false);
} }
@@ -220,30 +221,33 @@ export const LightboxComponent: React.FC<IProps> = ({
} }
setResetPosition((r) => !r); setResetPosition((r) => !r);
if (carouselRef.current) oldIndex.current = index;
carouselRef.current.style.left = `${index * -100}vw`; }, [index, images.length, lightboxSettings?.resetZoomOnNav]);
if (indicatorRef.current)
indicatorRef.current.innerHTML = `${index + 1} / ${images.length}`; const getNavOffset = useCallback(() => {
if (images.length < 2) return;
if (index === undefined || index === null) return;
if (navRef.current) { if (navRef.current) {
const currentThumb = navRef.current.children[index + 1]; const currentThumb = navRef.current.children[index + 1];
if (currentThumb instanceof HTMLImageElement) { if (currentThumb instanceof HTMLImageElement) {
const offset = const offset =
-1 * -1 *
(currentThumb.offsetLeft - document.documentElement.clientWidth / 2); (currentThumb.offsetLeft - document.documentElement.clientWidth / 2);
navRef.current.style.left = `${offset}px`;
const previouslySelected = navRef.current.getElementsByClassName( return { left: `${offset}px` };
CLASSNAME_NAVSELECTED
)?.[0];
if (previouslySelected)
previouslySelected.className = CLASSNAME_NAVIMAGE;
currentThumb.className = `${CLASSNAME_NAVIMAGE} ${CLASSNAME_NAVSELECTED}`;
} }
} }
}, [index, images.length]);
oldIndex.current = index; useEffect(() => {
}, [index, images.length, lightboxSettings?.resetZoomOnNav]); // reset images loaded counter for new images
setImagesLoaded(0);
}, [images]);
useEffect(() => {
setNavOffset(getNavOffset() ?? undefined);
}, [getNavOffset]);
useEffect(() => { useEffect(() => {
if (displayMode !== oldDisplayMode.current) { if (displayMode !== oldDisplayMode.current) {
@@ -313,6 +317,7 @@ export const LightboxComponent: React.FC<IProps> = ({
if (pageCallback) { if (pageCallback) {
pageCallback(-1); pageCallback(-1);
setIndex(-1); setIndex(-1);
oldImages.current = images;
setIsSwitchingPage(true); setIsSwitchingPage(true);
} else setIndex(images.length - 1); } else setIndex(images.length - 1);
} else setIndex((index ?? 0) - 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 // go to preview page, or loop back if no callback is set
if (pageCallback) { if (pageCallback) {
pageCallback(1); pageCallback(1);
oldImages.current = images;
setIsSwitchingPage(true); setIsSwitchingPage(true);
setIndex(0); setIndex(0);
} else setIndex(0); } else setIndex(0);
@@ -396,6 +402,15 @@ export const LightboxComponent: React.FC<IProps> = ({
else document.exitFullscreen(); else document.exitFullscreen();
}, [isFullscreen]); }, [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) => ( const navItems = images.map((image, i) => (
<img <img
src={image.paths.thumbnail ?? ""} src={image.paths.thumbnail ?? ""}
@@ -407,6 +422,7 @@ export const LightboxComponent: React.FC<IProps> = ({
role="presentation" role="presentation"
loading="lazy" loading="lazy"
key={image.paths.thumbnail} key={image.paths.thumbnail}
onLoad={imageLoaded}
/> />
)); ));
@@ -763,7 +779,7 @@ export const LightboxComponent: React.FC<IProps> = ({
)} )}
</div> </div>
{showNavigation && !isFullscreen && images.length > 1 && ( {showNavigation && !isFullscreen && images.length > 1 && (
<div className={CLASSNAME_NAV} ref={navRef}> <div className={CLASSNAME_NAV} style={navOffset} ref={navRef}>
<Button <Button
variant="link" variant="link"
onClick={() => setIndex(images.length - 1)} 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 * as GQL from "src/core/generated-graphql";
import { LightboxContext, IState } from "./context"; import { LightboxContext, IState } from "./context";
@@ -39,27 +39,74 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
export const useGalleryLightbox = (id: string) => { export const useGalleryLightbox = (id: string) => {
const { setLightboxState } = useContext(LightboxContext); 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(() => { useEffect(() => {
if (data) if (data)
setLightboxState({ setLightboxState({
images: data.findGallery?.images ?? [],
isLoading: false, isLoading: false,
isVisible: true, isVisible: true,
images: data.findImages?.images ?? [],
pageCallback: pages > 1 ? handleLightBoxPage : undefined,
pageHeader: `Page ${page} / ${pages}`,
}); });
}, [setLightboxState, data]); }, [setLightboxState, data, handleLightBoxPage, page, pages]);
const show = () => { const show = () => {
if (data) if (data)
setLightboxState({ setLightboxState({
isLoading: false, isLoading: false,
isVisible: true, isVisible: true,
images: data.findGallery?.images ?? [], images: data.findImages?.images ?? [],
pageCallback: undefined, pageCallback: pages > 1 ? handleLightBoxPage : undefined,
pageHeader: undefined, pageHeader: `Page ${page} / ${pages}`,
}); });
else { else {
setLightboxState({ setLightboxState({