mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Improve gallery performance (#3183)
* Improve cover resolver performance * Deprecate and remove usage of slow gallery images
This commit is contained in:
@@ -16,9 +16,6 @@ fragment GalleryData on Gallery {
|
|||||||
...FolderData
|
...FolderData
|
||||||
}
|
}
|
||||||
|
|
||||||
images {
|
|
||||||
...SlimImageData
|
|
||||||
}
|
|
||||||
cover {
|
cover {
|
||||||
...SlimImageData
|
...SlimImageData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user