Gallery scrubber (#5133)

This commit is contained in:
WithoutPants 2024-08-28 08:59:41 +10:00 committed by GitHub
parent ce47efc415
commit 996dfb1c2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 501 additions and 102 deletions

View file

@ -1,3 +1,7 @@
type GalleryPathsType {
preview: String! # Resolver
}
"Gallery type"
type Gallery {
id: ID!
@ -25,6 +29,9 @@ type Gallery {
performers: [Performer!]!
cover: Image
paths: GalleryPathsType! # Resolver
image(index: Int!): Image!
}
input GalleryCreateInput {

View file

@ -5,8 +5,8 @@ package api
type key int
const (
// galleryKey key = 0
performerKey key = iota + 1
galleryKey key = 0
performerKey
sceneKey
studioKey
groupKey

View file

@ -2,8 +2,10 @@ package api
import (
"context"
"fmt"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/image"
@ -189,3 +191,28 @@ func (r *galleryResolver) Urls(ctx context.Context, obj *models.Gallery) ([]stri
return obj.URLs.List(), nil
}
func (r *galleryResolver) Paths(ctx context.Context, obj *models.Gallery) (*GalleryPathsType, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewGalleryURLBuilder(baseURL, obj)
previewPath := builder.GetPreviewURL()
return &GalleryPathsType{
Preview: previewPath,
}, nil
}
func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index int) (ret *models.Image, err error) {
if index < 0 {
return nil, fmt.Errorf("index must >= 0")
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Image.FindByGalleryIDIndex(ctx, obj.ID, uint(index))
return err
}); err != nil {
return nil, err
}
return
}

View file

@ -0,0 +1,116 @@
package api
import (
"context"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
type GalleryFinder interface {
models.GalleryGetter
FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error)
}
type ImageByIndexer interface {
FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error)
}
type galleryRoutes struct {
routes
imageRoutes imageRoutes
galleryFinder GalleryFinder
imageFinder ImageByIndexer
fileGetter models.FileGetter
}
func (rs galleryRoutes) Routes() chi.Router {
r := chi.NewRouter()
r.Route("/{galleryId}", func(r chi.Router) {
r.Use(rs.GalleryCtx)
r.Get("/preview/{imageIndex}", rs.Preview)
})
return r
}
func (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) {
g := r.Context().Value(galleryKey).(*models.Gallery)
indexQueryParam := chi.URLParam(r, "imageIndex")
var i *models.Image
index, err := strconv.Atoi(indexQueryParam)
if err != nil || index < 0 {
http.Error(w, "bad index", 400)
return
}
_ = rs.withReadTxn(r, func(ctx context.Context) error {
qb := rs.imageFinder
i, _ = qb.FindByGalleryIDIndex(ctx, g.ID, uint(index))
// TODO - handle errors?
// serveThumbnail needs files populated
if err := i.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {
if !errors.Is(err, context.Canceled) {
logger.Errorf("error loading primary file for image %d: %v", i.ID, err)
}
// set image to nil so that it doesn't try to use the primary file
i = nil
}
return nil
})
if i == nil {
http.Error(w, http.StatusText(404), 404)
return
}
rs.imageRoutes.serveThumbnail(w, r, i)
}
func (rs galleryRoutes) GalleryCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
galleryIdentifierQueryParam := chi.URLParam(r, "galleryId")
galleryID, _ := strconv.Atoi(galleryIdentifierQueryParam)
var gallery *models.Gallery
_ = rs.withReadTxn(r, func(ctx context.Context) error {
qb := rs.galleryFinder
if galleryID == 0 {
galleries, _ := qb.FindByChecksum(ctx, galleryIdentifierQueryParam)
if len(galleries) > 0 {
gallery = galleries[0]
}
} else {
gallery, _ = qb.Find(ctx, galleryID)
}
if gallery != nil {
if err := gallery.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {
if !errors.Is(err, context.Canceled) {
logger.Errorf("error loading primary file for gallery %d: %v", galleryID, err)
}
// set image to nil so that it doesn't try to use the primary file
gallery = nil
}
}
return nil
})
if gallery == nil {
http.Error(w, http.StatusText(404), 404)
return
}
ctx := context.WithValue(r.Context(), galleryKey, gallery)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View file

@ -46,8 +46,12 @@ func (rs imageRoutes) Routes() chi.Router {
}
func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
mgr := manager.GetInstance()
img := r.Context().Value(imageKey).(*models.Image)
rs.serveThumbnail(w, r, img)
}
func (rs imageRoutes) serveThumbnail(w http.ResponseWriter, r *http.Request, img *models.Image) {
mgr := manager.GetInstance()
filepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
// if the thumbnail doesn't exist, encode on the fly

View file

@ -207,6 +207,7 @@ func Initialize() (*Server, error) {
r.Mount("/performer", server.getPerformerRoutes())
r.Mount("/scene", server.getSceneRoutes())
r.Mount("/gallery", server.getGalleryRoutes())
r.Mount("/image", server.getImageRoutes())
r.Mount("/studio", server.getStudioRoutes())
r.Mount("/group", server.getGroupRoutes())
@ -326,6 +327,16 @@ func (s *Server) getSceneRoutes() chi.Router {
}.Routes()
}
func (s *Server) getGalleryRoutes() chi.Router {
repo := s.manager.Repository
return galleryRoutes{
routes: routes{txnManager: repo.TxnManager},
imageFinder: repo.Image,
galleryFinder: repo.Gallery,
fileGetter: repo.File,
}.Routes()
}
func (s *Server) getImageRoutes() chi.Router {
repo := s.manager.Repository
return imageRoutes{

View file

@ -0,0 +1,23 @@
package urlbuilders
import (
"strconv"
"github.com/stashapp/stash/pkg/models"
)
type GalleryURLBuilder struct {
BaseURL string
GalleryID string
}
func NewGalleryURLBuilder(baseURL string, gallery *models.Gallery) GalleryURLBuilder {
return GalleryURLBuilder{
BaseURL: baseURL,
GalleryID: strconv.Itoa(gallery.ID),
}
}
func (b GalleryURLBuilder) GetPreviewURL() string {
return b.BaseURL + "/gallery/" + b.GalleryID + "/preview"
}

View file

@ -301,6 +301,29 @@ func (_m *ImageReaderWriter) FindByGalleryID(ctx context.Context, galleryID int)
return r0, r1
}
// FindByGalleryIDIndex provides a mock function with given fields: ctx, galleryID, index
func (_m *ImageReaderWriter) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) {
ret := _m.Called(ctx, galleryID, index)
var r0 *models.Image
if rf, ok := ret.Get(0).(func(context.Context, int, uint) *models.Image); ok {
r0 = rf(ctx, galleryID, index)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Image)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int, uint) error); ok {
r1 = rf(ctx, galleryID, index)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByZipFileID provides a mock function with given fields: ctx, zipFileID
func (_m *ImageReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) {
ret := _m.Called(ctx, zipFileID)

View file

@ -18,6 +18,7 @@ type ImageFinder interface {
FindByFolderID(ctx context.Context, fileID FolderID) ([]*Image, error)
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Image, error)
FindByGalleryID(ctx context.Context, galleryID int) ([]*Image, error)
FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*Image, error)
}
// ImageQueryer provides methods to query images.

View file

@ -568,8 +568,6 @@ func (qb *ImageStore) FindByChecksum(ctx context.Context, checksum string) ([]*m
func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) {
table := qb.table()
fileTable := fileTableMgr.table
folderTable := folderTableMgr.table
sq := dialect.From(table).
InnerJoin(
@ -584,7 +582,7 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo
table.Col(idColumn).Eq(
sq,
),
).Order(folderTable.Col("path").Asc(), fileTable.Col("basename").Asc())
).Order(goqu.L("COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI").Asc())
ret, err := qb.getMany(ctx, q)
if err != nil {
@ -594,6 +592,33 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo
return ret, nil
}
func (qb *ImageStore) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) {
table := qb.table()
fileTable := fileTableMgr.table
folderTable := folderTableMgr.table
q := qb.selectDataset().
InnerJoin(
galleriesImagesJoinTable,
goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))),
).
Where(galleriesImagesJoinTable.Col(galleryIDColumn).Eq(galleryID)).
Prepared(true).
Order(folderTable.Col("path").Asc(), fileTable.Col("basename").Asc()).
Limit(1).Offset(index)
ret, err := qb.getMany(ctx, q)
if err != nil {
return nil, fmt.Errorf("getting images for gallery %d: %w", galleryID, err)
}
if len(ret) == 0 {
return nil, nil
}
return ret[0], nil
}
func (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, error) {
joinTable := goqu.T(galleriesImagesTable)

View file

@ -48,4 +48,7 @@ fragment SlimGalleryData on Gallery {
scenes {
...SlimSceneData
}
paths {
preview
}
}

View file

@ -11,6 +11,10 @@ fragment GalleryData on Gallery {
rating100
organized
paths {
preview
}
files {
...GalleryFileData
}
@ -52,6 +56,9 @@ fragment SelectGalleryData on Gallery {
thumbnail
}
}
paths {
preview
}
files {
path
}

View file

@ -28,3 +28,11 @@ query FindGalleriesForSelect(
}
}
}
query FindGalleryImageID($id: ID!, $index: Int!) {
findGallery(id: $id) {
image(index: $index) {
id
}
}
}

View file

@ -1,5 +1,5 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
import { Helmet } from "react-helmet";
import { useTitleProps } from "src/hooks/title";
import Gallery from "./GalleryDetails/Gallery";
@ -7,6 +7,38 @@ import GalleryCreate from "./GalleryDetails/GalleryCreate";
import { GalleryList } from "./GalleryList";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
import { View } from "../List/views";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { ErrorMessage } from "../Shared/ErrorMessage";
import { useFindGalleryImageID } from "src/core/StashService";
interface IGalleryImageParams {
id: string;
index: string;
}
const GalleryImage: React.FC<RouteComponentProps<IGalleryImageParams>> = ({
match,
}) => {
const { id, index: indexStr } = match.params;
let index = parseInt(indexStr);
if (isNaN(index)) {
index = 0;
}
const { data, loading, error } = useFindGalleryImageID(id, index);
if (isNaN(index)) {
return <Redirect to={`/galleries/${id}`} />;
}
if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findGallery)
return <ErrorMessage error={`No gallery found with id ${id}.`} />;
return <Redirect to={`/images/${data.findGallery.image.id}`} />;
};
const Galleries: React.FC = () => {
useScrollToTopOnMount();
@ -22,6 +54,11 @@ const GalleryRoutes: React.FC = () => {
<Switch>
<Route exact path="/galleries" component={Galleries} />
<Route exact path="/galleries/new" component={GalleryCreate} />
<Route
exact
path="/galleries/:id/images/:index"
component={GalleryImage}
/>
<Route path="/galleries/:id/:tab?" component={Gallery} />
</Switch>
</>

View file

@ -14,6 +14,45 @@ import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
import ScreenUtils from "src/utils/screen";
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber";
import cx from "classnames";
import { useHistory } from "react-router-dom";
interface IScenePreviewProps {
isPortrait?: boolean;
gallery: GQL.SlimGalleryDataFragment;
onScrubberClick?: (index: number) => void;
}
export const GalleryPreview: React.FC<IScenePreviewProps> = ({
gallery,
isPortrait = false,
onScrubberClick,
}) => {
const [imgSrc, setImgSrc] = useState<string | undefined>(
gallery.cover?.paths.thumbnail ?? undefined
);
return (
<div className={cx("gallery-card-cover", { portrait: isPortrait })}>
{!!imgSrc && (
<img
loading="lazy"
className="gallery-card-image"
alt={gallery.title ?? ""}
src={imgSrc}
/>
)}
<GalleryPreviewScrubber
previewPath={gallery.paths.preview}
defaultPath={gallery.cover?.paths.thumbnail ?? ""}
imageCount={gallery.image_count}
onClick={onScrubberClick}
onPathChanged={setImgSrc}
/>
</div>
);
};
interface IProps {
gallery: GQL.SlimGalleryDataFragment;
@ -25,6 +64,7 @@ interface IProps {
}
export const GalleryCard: React.FC<IProps> = (props) => {
const history = useHistory();
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
@ -167,14 +207,13 @@ export const GalleryCard: React.FC<IProps> = (props) => {
linkClassName="gallery-card-header"
image={
<>
{props.gallery.cover ? (
<img
loading="lazy"
className="gallery-card-image"
alt={props.gallery.title ?? ""}
src={`${props.gallery.cover.paths.thumbnail}`}
<GalleryPreview
gallery={props.gallery}
onScrubberClick={(i) => {
console.log(i);
history.push(`/galleries/${props.gallery.id}/images/${i}`);
}}
/>
) : undefined}
<RatingBanner rating={props.gallery.rating100} />
</>
}

View file

@ -0,0 +1,54 @@
import React, { useEffect, useState } from "react";
import { useThrottle } from "src/hooks/throttle";
import { HoverScrubber } from "../Shared/HoverScrubber";
import cx from "classnames";
export const GalleryPreviewScrubber: React.FC<{
className?: string;
previewPath: string;
defaultPath: string;
imageCount: number;
onClick?: (imageIndex: number) => void;
onPathChanged: React.Dispatch<React.SetStateAction<string | undefined>>;
}> = ({
className,
previewPath,
defaultPath,
imageCount,
onClick,
onPathChanged,
}) => {
const [activeIndex, setActiveIndex] = useState<number>();
const debounceSetActiveIndex = useThrottle(setActiveIndex, 50);
function onScrubberClick() {
if (activeIndex === undefined || !onClick) {
return;
}
onClick(activeIndex);
}
useEffect(() => {
function getPath() {
if (activeIndex === undefined) {
return defaultPath;
}
return `${previewPath}/${activeIndex}`;
}
onPathChanged(getPath());
}, [activeIndex, defaultPath, previewPath, onPathChanged]);
return (
<div className={cx("preview-scrubber", className)}>
<HoverScrubber
totalSprites={imageCount}
activeIndex={activeIndex}
setActiveIndex={(i) => debounceSetActiveIndex(i)}
onClick={() => onScrubberClick()}
/>
</div>
);
};

View file

@ -102,6 +102,14 @@
color: $text-color;
}
&-cover {
position: relative;
}
.preview-scrubber {
top: 0;
}
&-image {
object-fit: contain;
}

View file

@ -8,89 +8,7 @@ import React, {
import { useSpriteInfo } from "src/hooks/sprite";
import { useThrottle } from "src/hooks/throttle";
import TextUtils from "src/utils/text";
import cx from "classnames";
interface IHoverScrubber {
totalSprites: number;
activeIndex: number | undefined;
setActiveIndex: (index: number | undefined) => void;
onClick?: () => void;
}
const HoverScrubber: React.FC<IHoverScrubber> = ({
totalSprites,
activeIndex,
setActiveIndex,
onClick,
}) => {
function getActiveIndex(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const { width } = e.currentTarget.getBoundingClientRect();
const x = e.nativeEvent.offsetX;
const i = Math.floor((x / width) * totalSprites);
// clamp to [0, totalSprites)
if (i < 0) return 0;
if (i >= totalSprites) return totalSprites - 1;
return i;
}
function onMouseMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const relatedTarget = e.currentTarget;
if (relatedTarget !== e.target) return;
setActiveIndex(getActiveIndex(e));
}
function onMouseLeave() {
setActiveIndex(undefined);
}
function onScrubberClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (!onClick) return;
const relatedTarget = e.currentTarget;
if (relatedTarget !== e.target) return;
e.preventDefault();
onClick();
}
const indicatorStyle = useMemo(() => {
if (activeIndex === undefined || !totalSprites) return {};
const width = (activeIndex / totalSprites) * 100;
return {
width: `${width}%`,
};
}, [activeIndex, totalSprites]);
return (
<div
className={cx("hover-scrubber", {
"hover-scrubber-inactive": !totalSprites,
})}
>
<div
className="hover-scrubber-area"
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onClick={onScrubberClick}
/>
<div className="hover-scrubber-indicator">
{activeIndex !== undefined && (
<div
className="hover-scrubber-indicator-marker"
style={indicatorStyle}
></div>
)}
</div>
</div>
);
};
import { HoverScrubber } from "../Shared/HoverScrubber";
interface IScenePreviewProps {
vttPath: string | undefined;

View file

@ -0,0 +1,84 @@
import React, { useMemo } from "react";
import cx from "classnames";
interface IHoverScrubber {
totalSprites: number;
activeIndex: number | undefined;
setActiveIndex: (index: number | undefined) => void;
onClick?: () => void;
}
export const HoverScrubber: React.FC<IHoverScrubber> = ({
totalSprites,
activeIndex,
setActiveIndex,
onClick,
}) => {
function getActiveIndex(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const { width } = e.currentTarget.getBoundingClientRect();
const x = e.nativeEvent.offsetX;
const i = Math.round((x / width) * (totalSprites - 1));
// clamp to [0, totalSprites)
if (i < 0) return 0;
if (i >= totalSprites) return totalSprites - 1;
return i;
}
function onMouseMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const relatedTarget = e.currentTarget;
if (relatedTarget !== e.target) return;
setActiveIndex(getActiveIndex(e));
}
function onMouseLeave() {
setActiveIndex(undefined);
}
function onScrubberClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (!onClick) return;
const relatedTarget = e.currentTarget;
if (relatedTarget !== e.target) return;
e.preventDefault();
onClick();
}
const indicatorStyle = useMemo(() => {
if (activeIndex === undefined || !totalSprites) return {};
const width = ((activeIndex + 1) / totalSprites) * 100;
return {
width: `${width}%`,
};
}, [activeIndex, totalSprites]);
return (
<div
className={cx("hover-scrubber", {
"hover-scrubber-inactive": !totalSprites,
})}
>
<div
className="hover-scrubber-area"
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onClick={onScrubberClick}
/>
<div className="hover-scrubber-indicator">
{activeIndex !== undefined && (
<div
className="hover-scrubber-indicator-marker"
style={indicatorStyle}
></div>
)}
</div>
</div>
);
};

View file

@ -275,6 +275,10 @@ export const useFindGallery = (id: string) => {
return GQL.useFindGalleryQuery({ variables: { id }, skip });
};
export const useFindGalleryImageID = (id: string, index: number) => {
return GQL.useFindGalleryImageIdQuery({ variables: { id, index } });
};
export const useFindGalleries = (filter?: ListFilterModel) =>
GQL.useFindGalleriesQuery({
skip: filter === undefined,

View file

@ -500,7 +500,7 @@ textarea.text-input {
.zoom-0 {
.gallery-card-image,
.tag-card-image {
max-height: 180px;
height: 180px;
}
}
@ -509,7 +509,7 @@ textarea.text-input {
.gallery-card-image,
.tag-card-image {
max-height: 240px;
height: 240px;
}
.image-card-preview {
@ -520,7 +520,7 @@ textarea.text-input {
.zoom-2 {
.gallery-card-image,
.tag-card-image {
max-height: 360px;
height: 360px;
}
.image-card-preview {
@ -531,7 +531,7 @@ textarea.text-input {
.zoom-3 {
.tag-card-image,
.gallery-card-image {
max-height: 480px;
height: 480px;
}
.image-card-preview {