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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ import {
InputGroup, InputGroup,
Spinner, Spinner,
TextArea, TextArea,
Collapse,
Icon,
FileInput,
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import _ from "lodash"; import _ from "lodash";
import React, { FunctionComponent, useEffect, useState } from "react"; import React, { FunctionComponent, useEffect, useState } from "react";
@@ -18,6 +21,7 @@ import { ToastUtils } from "../../../utils/toasts";
import { FilterMultiSelect } from "../../select/FilterMultiSelect"; import { FilterMultiSelect } from "../../select/FilterMultiSelect";
import { FilterSelect } from "../../select/FilterSelect"; import { FilterSelect } from "../../select/FilterSelect";
import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect"; import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect";
import { ImageUtils } from "../../../utils/image";
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -36,11 +40,15 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
const [studioId, setStudioId] = useState<string | undefined>(undefined); const [studioId, setStudioId] = useState<string | undefined>(undefined);
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined); const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
const [tagIds, setTagIds] = 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 [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [deleteFile, setDeleteFile] = useState<boolean>(false); const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true); const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false);
const [coverImagePreview, setCoverImagePreview] = useState<string | undefined>(undefined);
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -64,8 +72,11 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
useEffect(() => { useEffect(() => {
updateSceneEditState(props.scene); updateSceneEditState(props.scene);
setCoverImagePreview(props.scene.paths.screenshot);
}, [props.scene]); }, [props.scene]);
ImageUtils.addPasteImageHook(onImageLoad);
// if (!isNew && !isEditing) { // if (!isNew && !isEditing) {
// if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; } // if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
// if (!!error) { return <>error...</>; } // if (!!error) { return <>error...</>; }
@@ -83,6 +94,7 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
studio_id: studioId, studio_id: studioId,
performer_ids: performerIds, performer_ids: performerIds,
tag_ids: tagIds, 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 ( return (
<> <>
{renderDeleteAlert()} {renderDeleteAlert()}
@@ -231,6 +252,18 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
<FormGroup label="Tags"> <FormGroup label="Tags">
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</FormGroup> </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> </div>
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/> <Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
<Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/> <Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/>

View File

@@ -478,6 +478,26 @@ span.block {
font-weight: 300; 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) { @media only screen and (max-width: 768px) {
// collapse menu items into sidemenu // collapse menu items into sidemenu
.collapsible-navlink { .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);
});
}
}