Allow uploading of custom scene covers (#262)

* Refactor common code

* Further refactoring

* Add UI support for changing scene cover image

* Add backend support for changing scene screenshot
This commit is contained in:
WithoutPants
2019-12-14 07:40:58 +11:00
committed by Leopere
parent c05496a724
commit da3e91193c
9 changed files with 186 additions and 55 deletions

View File

@@ -8,7 +8,8 @@ mutation SceneUpdate(
$studio_id: ID,
$gallery_id: ID,
$performer_ids: [ID!] = [],
$tag_ids: [ID!] = []) {
$tag_ids: [ID!] = [],
$cover_image: String) {
sceneUpdate(input: {
id: $id,
@@ -20,7 +21,8 @@ mutation SceneUpdate(
studio_id: $studio_id,
gallery_id: $gallery_id,
performer_ids: $performer_ids,
tag_ids: $tag_ids
tag_ids: $tag_ids,
cover_image: $cover_image
}) {
...SceneData
}

View File

@@ -51,6 +51,8 @@ input SceneUpdateInput {
gallery_id: ID
performer_ids: [ID!]
tag_ids: [ID!]
"""This should be base64 encoded"""
cover_image: String
}
input BulkSceneUpdateInput {

View File

@@ -11,6 +11,7 @@ import (
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (*models.Scene, error) {
@@ -149,6 +150,24 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, tx *sqlx.T
return nil, err
}
// only update the cover image if provided and everything else was successful
if input.CoverImage != nil && *input.CoverImage != "" {
_, imageData, err := utils.ProcessBase64Image(*input.CoverImage)
if err != nil {
return nil, err
}
scene, err := qb.Find(sceneID)
if err != nil {
return nil, err
}
err = manager.SetSceneScreenshot(scene.Checksum, imageData)
if err != nil {
return nil, err
}
}
return scene, nil
}

View File

@@ -0,0 +1,61 @@
package manager
import (
"bytes"
"image"
"image/jpeg"
"os"
"github.com/disintegration/imaging"
// needed to decode other image formats
_ "image/gif"
_ "image/png"
)
func writeImage(path string, imageData []byte) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(imageData)
return err
}
func writeThumbnail(path string, thumbnail image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return jpeg.Encode(f, thumbnail, nil)
}
func SetSceneScreenshot(checksum string, imageData []byte) error {
thumbPath := instance.Paths.Scene.GetThumbnailScreenshotPath(checksum)
normalPath := instance.Paths.Scene.GetScreenshotPath(checksum)
img, _, err := image.Decode(bytes.NewReader(imageData))
if err != nil {
return err
}
// resize to 320 width maintaining aspect ratio, for the thumbnail
const width = 320
origWidth := img.Bounds().Max.X
origHeight := img.Bounds().Max.Y
height := width / origWidth * origHeight
thumbnail := imaging.Resize(img, width, height, imaging.Lanczos)
err = writeThumbnail(thumbPath, thumbnail)
if err != nil {
return err
}
err = writeImage(normalPath, imageData)
return err
}

View File

@@ -16,6 +16,7 @@ import { ErrorUtils } from "../../../utils/errors";
import { TableUtils } from "../../../utils/table";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
import { ToastUtils } from "../../../utils/toasts";
import { ImageUtils } from "../../../utils/image";
interface IProps extends IBaseProps {}
@@ -62,26 +63,12 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
}
}, [studio]);
function pasteImage(e : any) {
if (e.clipboardData.files.length == 0) {
return;
}
const file: File = e.clipboardData.files[0];
const reader: FileReader = new FileReader();
reader.onloadend = (e) => {
setImagePreview(reader.result as string);
setImage(reader.result as string);
};
reader.readAsDataURL(file);
function onImageLoad(this: FileReader) {
setImagePreview(this.result as string);
setImage(this.result as string);
}
useEffect(() => {
window.addEventListener("paste", pasteImage);
return () => window.removeEventListener("paste", pasteImage);
});
ImageUtils.addPasteImageHook(onImageLoad);
if (!isNew && !isEditing) {
if (!data || !data.findStudio || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
@@ -144,14 +131,7 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
const file: File = (event.target as any).files[0];
const reader: FileReader = new FileReader();
reader.onloadend = (e) => {
setImagePreview(reader.result as string);
setImage(reader.result as string);
};
reader.readAsDataURL(file);
ImageUtils.onImageChange(event, onImageLoad);
}
// TODO: CSS class

View File

@@ -18,6 +18,7 @@ import { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
import { ToastUtils } from "../../../utils/toasts";
import { EditableTextUtils } from "../../../utils/editabletext";
import { ImageUtils } from "../../../utils/image";
interface IPerformerProps extends IBaseProps {}
@@ -99,26 +100,12 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
}
}, [performer]);
function pasteImage(e : any) {
if (e.clipboardData.files.length == 0) {
return;
}
const file: File = e.clipboardData.files[0];
const reader: FileReader = new FileReader();
reader.onloadend = (e) => {
setImagePreview(reader.result as string);
setImage(reader.result as string);
};
reader.readAsDataURL(file);
function onImageLoad(this: FileReader) {
setImagePreview(this.result as string);
setImage(this.result as string);
}
useEffect(() => {
window.addEventListener("paste", pasteImage);
return () => window.removeEventListener("paste", pasteImage);
});
ImageUtils.addPasteImageHook(onImageLoad);
useEffect(() => {
var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = [];
@@ -208,14 +195,7 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
const file: File = (event.target as any).files[0];
const reader: FileReader = new FileReader();
reader.onloadend = (e) => {
setImagePreview(reader.result as string);
setImage(reader.result as string);
};
reader.readAsDataURL(file);
ImageUtils.onImageChange(event, onImageLoad);
}
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) {

View File

@@ -8,6 +8,9 @@ import {
InputGroup,
Spinner,
TextArea,
Collapse,
Icon,
FileInput,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useState } from "react";
@@ -18,6 +21,7 @@ import { ToastUtils } from "../../../utils/toasts";
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
import { FilterSelect } from "../../select/FilterSelect";
import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect";
import { ImageUtils } from "../../../utils/image";
interface IProps {
scene: GQL.SceneDataFragment;
@@ -36,11 +40,15 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
const [studioId, setStudioId] = useState<string | undefined>(undefined);
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
const [coverImage, setCoverImage] = useState<string | undefined>(undefined);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false);
const [coverImagePreview, setCoverImagePreview] = useState<string | undefined>(undefined);
// Network state
const [isLoading, setIsLoading] = useState(false);
@@ -64,8 +72,11 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
useEffect(() => {
updateSceneEditState(props.scene);
setCoverImagePreview(props.scene.paths.screenshot);
}, [props.scene]);
ImageUtils.addPasteImageHook(onImageLoad);
// if (!isNew && !isEditing) {
// if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
// if (!!error) { return <>error...</>; }
@@ -83,6 +94,7 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
studio_id: studioId,
performer_ids: performerIds,
tag_ids: tagIds,
cover_image: coverImage,
};
}
@@ -166,6 +178,15 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
);
}
function onImageLoad(this: FileReader) {
setCoverImagePreview(this.result as string);
setCoverImage(this.result as string);
}
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
return (
<>
{renderDeleteAlert()}
@@ -231,6 +252,18 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
<FormGroup label="Tags">
{renderMultiSelect("tags", tagIds)}
</FormGroup>
<div className="bp3-form-group">
<label className="bp3-label collapsible-label" onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}>
<Icon className="label-icon" icon={isCoverImageOpen ? "chevron-down" : "chevron-right"}/>
<span>Cover Image</span>
</label>
<Collapse isOpen={isCoverImageOpen}>
<img className="scene-cover" src={coverImagePreview} />
<FileInput text="Choose image..." onInputChange={onCoverImageChange} inputProps={{accept: ".jpg,.jpeg,.png"}} />
</Collapse>
</div>
</div>
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
<Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/>

View File

@@ -478,6 +478,26 @@ span.block {
font-weight: 300;
}
.scene-cover {
display: block;
margin-top: 10px;
margin-bottom: 10px;
max-width: 100%;
}
.collapsible-label {
cursor: pointer;
}
.label-icon {
margin-right: 0.3em;
vertical-align: middle;
& +span {
vertical-align: middle;
}
}
@media only screen and (max-width: 768px) {
// collapse menu items into sidemenu
.collapsible-navlink {

34
ui/v2/src/utils/image.tsx Normal file
View File

@@ -0,0 +1,34 @@
import React, { useEffect } from "react";
export class ImageUtils {
private static readImage(file: File, onLoadEnd: (this: FileReader) => any) {
const reader: FileReader = new FileReader();
reader.onloadend = onLoadEnd;
reader.readAsDataURL(file);
}
public static onImageChange(event: React.FormEvent<HTMLInputElement>, onLoadEnd: (this: FileReader) => any) {
const file: File = (event.target as any).files[0];
ImageUtils.readImage(file, onLoadEnd);
}
public static pasteImage(e : any, onLoadEnd: (this: FileReader) => any) {
if (e.clipboardData.files.length == 0) {
return;
}
const file: File = e.clipboardData.files[0];
ImageUtils.readImage(file, onLoadEnd);
}
public static addPasteImageHook(onLoadEnd: (this: FileReader) => any) {
useEffect(() => {
const pasteImage = (e: any) => { ImageUtils.pasteImage(e, onLoadEnd) }
window.addEventListener("paste", pasteImage);
return () => window.removeEventListener("paste", pasteImage);
});
}
}