mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
61
pkg/manager/scene_screenshot.go
Normal file
61
pkg/manager/scene_screenshot.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
function onImageLoad(this: FileReader) {
|
||||
setImagePreview(this.result as string);
|
||||
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(() => {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
function onImageLoad(this: FileReader) {
|
||||
setImagePreview(this.result as string);
|
||||
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(() => {
|
||||
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) {
|
||||
|
||||
@@ -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)}/>
|
||||
|
||||
@@ -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
34
ui/v2/src/utils/image.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user