Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor image resizing #667

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ jobs:
--health-retries 5

steps:

- uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: libvips-dev
version: 1.0

- name: Checkout
uses: actions/checkout@v3
with:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ jobs:
runs-on: ubuntu-22.04

steps:
# Silence embed linting error
- uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: libvips-dev
version: 1.0
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.22.x
# Silence embed linting error
- run: mkdir frontend/build && touch frontend/build/dummy
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
Expand Down
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
LDFLAGS := $(LDFLAGS)
export CGO_ENABLED = 0

.PHONY: \
stash-box \
Expand Down Expand Up @@ -114,13 +113,15 @@ cross-compile-windows: export GOOS := windows
cross-compile-windows: export GOARCH := amd64
cross-compile-windows: export CC := x86_64-w64-mingw32-gcc
cross-compile-windows: export CXX := x86_64-w64-mingw32-g++
cross-compile-windows: export CGO_ENABLED = 0
cross-compile-windows: OUTPUT := -o dist/stash-box-windows.exe
cross-compile-windows: build-release-static

cross-compile-linux: export GOOS := linux
cross-compile-linux: export GOARCH := amd64
cross-compile-linux: OUTPUT := -o dist/stash-box-linux
cross-compile-linux: build-release-static
cross-compile-linux: export CGO_ENABLED = 1
cross-compile-linux: build

cross-compile:
make cross-compile-windows
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ If you already have PostgreSQL installed, you can install stash-box on its own f

Stash-box supports macOS, Windows, and Linux. Releases for Windows and Linux can be found [here](https://github.com/stashapp/stash-box/releases).

## Prerequisites
To build stash-box on linux [libvips](https://www.libvips.org/) must be installed, as well as gcc.

## Initial setup

1. Run `make` to build the application.
Expand Down Expand Up @@ -84,9 +87,9 @@ There are two ways to authenticate a user in Stash-box: a session or an API key.
| `host_url` | (none) | Base URL for the server. Used when sending emails. Should be in the form of `https://hostname.com`. |
| `image_location` | (none) | Path to store images, for local image storage. An error will be displayed if this is not set when creating non-URL images. |
| `image_backend` | (`file`) | Storage solution for images. Can be set to either `file` or `s3`. |
| `image_max_size` | (none) | Max size of image, if no size is specified. Omit to return full size. |
| `userLogFile` | (none) | Path to the user log file, which logs user operations. If not set, then these will be output to stderr. |
| `s3.endpoint` | (none) | Hostname to s3 endpoint used for image storage. |
| `s3.base_url` | (none) | Base URL to access images in S3. Should be in the form of `https://hostname.com`. |
| `s3.bucket` | (none) | Name of S3 bucket used to store images. |
| `s3.access_key` | (none) | Access key used for authentication. |
| `s3.secret ` | (none) | Secret Access key used for authentication. |
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/form/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { Button } from "react-bootstrap";
import { faXmark } from "@fortawesome/free-solid-svg-icons";

import { Icon } from "src/components/fragments";
import Image from "src/components/image";
import { ImageFragment } from "src/graphql";

interface ImageProps {
image: Pick<ImageFragment, "id" | "url">;
image: Pick<ImageFragment, "id" | "url" | "width" | "height">;
onRemove: () => void;
}

Expand All @@ -23,7 +24,7 @@ const ImageInput: FC<ImageProps> = ({ image, onRemove }) => (
>
<Icon icon={faXmark} />
</Button>
<img src={image.url} className={CLASSNAME_IMAGE} alt="" />
<Image images={image} className={CLASSNAME_IMAGE} size="full" />
</div>
);

Expand Down
7 changes: 3 additions & 4 deletions frontend/src/components/form/styles.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.BodyModification {
&-remove {
z-index: 2;
background: transparent;
border: none;
color: rgba(0 0 0 / 50%);
Expand Down Expand Up @@ -44,13 +45,11 @@

&-image {
border-radius: 3px;
flex-shrink: 0;
max-height: 200px;
min-height: 100px;
min-width: 100px;
height: 200px;
}

&-remove {
z-index: 2;
left: 5px;
opacity: 0.6;
padding: 0 5px;
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/components/fragments/Thumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FC } from "react";

interface Props {
image: string;
size?: number;
alt?: string | null;
className?: string;
}

export const Thumbnail: FC<Props> = ({ image, size, alt, className }) => (
<img
alt={alt ?? ""}
className={className}
src={image + (size ? `?size=${size}` : "")}
srcSet={size ? `${image}?size=${size * 2} ${size * 2}w` : ""}
/>
);
1 change: 1 addition & 0 deletions frontend/src/components/fragments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export { default as ErrorMessage } from "./ErrorMessage";
export { default as Help } from "./Help";
export { default as Tooltip } from "./Tooltip";
export { FavoriteStar } from "./Favorite";
export { Thumbnail } from "./Thumbnail";
export { SearchHint } from "./SearchHint";
82 changes: 58 additions & 24 deletions frontend/src/components/image/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,58 @@
import { FC, useState } from "react";
import cx from "classnames";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { getImage } from "src/utils";
import { sortImageURLs } from "src/utils";
import { LoadingIndicator, Icon } from "src/components/fragments";
import { ImageFragment } from "src/graphql";

const CLASSNAME = "Image";

interface Props {
images: ImageFragment[] | ImageFragment;
orientation?: "landscape" | "portrait";
type Image = {
url: string;
width: number;
height: number;
};

interface ImageProps {
image: Image;
emptyMessage?: string;
size?: number | "full";
alt?: string;
}

const Image: FC<Props> = ({
images,
orientation = "landscape",
const ImageComponent: FC<ImageProps> = ({
image,
emptyMessage = "No image",
size,
alt,
}) => {
const url = Array.isArray(images)
? getImage(images, orientation)
: images.url;
const [imageState, setImageState] = useState<"loading" | "error" | "done">(
"loading"
);

if (!url) return <div className={`${CLASSNAME}-missing`}>{emptyMessage}</div>;
if (!image.url)
return (
<div className={`${CLASSNAME}-missing`}>
<Icon icon={faXmark} color="var(--bs-gray-400)" />
<div>{emptyMessage}</div>
</div>
);

const sizeQuery = size ? `?size=${size}` : "";

return (
<>
{imageState === "loading" && (
<LoadingIndicator message="Loading image..." delay={200} />
)}
{imageState === "error" && (
<div>
<span className="me-2">
<Icon icon={faXmark} color="red" />
</span>
<span>Failed to load image</span>
<div className="Image-error">
<Icon icon={faXmark} color="red" />
<div>Failed to load image</div>
</div>
)}
<img
alt=""
src={url}
alt={alt ?? ""}
src={`${image.url}${sizeQuery}`}
className={`${CLASSNAME}-image`}
onLoad={() => setImageState("done")}
onError={() => setImageState("error")}
Expand All @@ -50,9 +61,32 @@ const Image: FC<Props> = ({
);
};

const ImageContainer: FC<Props> = (props) => (
<div className={CLASSNAME}>
<Image {...props} />
</div>
);
interface ContainerProps {
images: Image[] | Image;
orientation?: "landscape" | "portrait";
emptyMessage?: string;
size?: number | "full";
alt?: string;
className?: string;
}

const ImageContainer: FC<ContainerProps> = ({
className,
images,
orientation = "landscape",
...props
}) => {
const image = Array.isArray(images)
? sortImageURLs(images, orientation)[0]
: images;

return (
<div
className={cx(CLASSNAME, className)}
style={{ aspectRatio: `${image.width}/${image.height}` }}
>
<ImageComponent {...props} image={image} />
</div>
);
};
export default ImageContainer;
41 changes: 30 additions & 11 deletions frontend/src/components/image/styles.scss
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
.Image {
width: 100%;
height: 100%;
flex-grow: 1;
font-size: 2rem;
line-height: 2rem;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-height: 100%;
max-width: 100%;

&-image {
position: relative;
width: 100%;
max-width: 100%;
max-height: 100%;
z-index: 1;
}

.LoadingIndicator {
position: absolute;
height: unset;
background-color: $gray-600;
border-radius: 4px;
height: 100%;
width: 100%;
text-align: center;
}

&-error,
&-missing {
position: absolute;
background-color: $secondary;
width: 100%;
height: 100%;
align-content: center;
text-align: center;
font-size: 24px;

.fa-icon {
border-radius: 4px;
width: 70px;
height: 70px;
max-width: 100%;
max-height: 100%;
}
}
}
1 change: 1 addition & 0 deletions frontend/src/components/imageCarousel/ImageCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const ImageCarousel: FC<ImageCarouselProps> = ({ images, orientation }) => {
<Image
images={sortedImages[imageIndex]}
key={sortedImages[imageIndex].url}
size={600}
/>
</div>
<Button
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/imageChangeRow/ImageChangeRow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FC } from "react";
import { Col, Row } from "react-bootstrap";
import ImageComponent from "src/components/image";

type Image = {
height: number;
Expand All @@ -26,9 +27,9 @@ const Images: FC<{
image === null ? (
<img className={CLASSNAME_IMAGE} alt="Deleted" key={`deleted-${i}`} />
) : (
<div key={image.id}>
<img src={image.url} className={CLASSNAME_IMAGE} alt="" />
<div className={"text-center"}>
<div key={image.id} className={CLASSNAME_IMAGE}>
<ImageComponent images={image} alt="" size="full" />
<div className="text-center">
{image.width} x {image.height}
</div>
</div>
Expand Down
9 changes: 2 additions & 7 deletions frontend/src/components/imageChangeRow/styles.scss
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
.ImageChangeRow {
display: flex;
flex-wrap: wrap;
overflow: hidden;

&-image {
border-radius: 3px;
max-height: 150px;
.Image {
height: 150px;
margin: 5px;
object-fit: cover;
object-position: top;
max-width: 100%;
}
}
7 changes: 4 additions & 3 deletions frontend/src/components/performerCard/PerformerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
GenderIcon,
FavoriteStar,
PerformerName,
Thumbnail,
} from "src/components/fragments";
import { getImage, performerHref } from "src/utils";

Expand All @@ -30,10 +31,10 @@ const PerformerCard: FC<PerformerCardProps> = ({ className, performer }) => (
<Card className={cx(CLASSNAME, className)}>
<Link to={performerHref(performer)}>
<div className={CLASSNAME_IMAGE}>
<img
src={getImage(performer.images, "portrait")}
<Thumbnail
image={getImage(performer.images, "portrait")}
alt={performer.name}
title={performer.name}
size={350}
/>
<FavoriteStar
entity={performer}
Expand Down
Loading
Loading