From da3e91193cbe2ded3a57c1384c1a1a8b8eb2dae5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 14 Dec 2019 07:40:58 +1100 Subject: [PATCH] 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 --- graphql/documents/mutations/scene.graphql | 6 +- graphql/schema/types/scene.graphql | 2 + pkg/api/resolver_mutation_scene.go | 21 ++++++- pkg/manager/scene_screenshot.go | 61 +++++++++++++++++++ .../Studios/StudioDetails/Studio.tsx | 32 ++-------- .../performers/PerformerDetails/Performer.tsx | 32 ++-------- .../scenes/SceneDetails/SceneEditPanel.tsx | 33 ++++++++++ ui/v2/src/index.scss | 20 ++++++ ui/v2/src/utils/image.tsx | 34 +++++++++++ 9 files changed, 186 insertions(+), 55 deletions(-) create mode 100644 pkg/manager/scene_screenshot.go create mode 100644 ui/v2/src/utils/image.tsx diff --git a/graphql/documents/mutations/scene.graphql b/graphql/documents/mutations/scene.graphql index 5cdc021b1..dac4a9970 100644 --- a/graphql/documents/mutations/scene.graphql +++ b/graphql/documents/mutations/scene.graphql @@ -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 } diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index e526dca14..7e6764630 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -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 { diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index 58230d3a8..9fe3b8674 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -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 } @@ -283,7 +302,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD if err := tx.Commit(); err != nil { return false, err } - + // if delete generated is true, then delete the generated files // for the scene if input.DeleteGenerated != nil && *input.DeleteGenerated { diff --git a/pkg/manager/scene_screenshot.go b/pkg/manager/scene_screenshot.go new file mode 100644 index 000000000..0e410ae5d --- /dev/null +++ b/pkg/manager/scene_screenshot.go @@ -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 +} diff --git a/ui/v2/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2/src/components/Studios/StudioDetails/Studio.tsx index d687b562b..2673b2705 100644 --- a/ui/v2/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2/src/components/Studios/StudioDetails/Studio.tsx @@ -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 = (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 ; } @@ -144,14 +131,7 @@ export const Studio: FunctionComponent = (props: IProps) => { } function onImageChange(event: React.FormEvent) { - 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 diff --git a/ui/v2/src/components/performers/PerformerDetails/Performer.tsx b/ui/v2/src/components/performers/PerformerDetails/Performer.tsx index 559ba6979..6a09d1cf0 100644 --- a/ui/v2/src/components/performers/PerformerDetails/Performer.tsx +++ b/ui/v2/src/components/performers/PerformerDetails/Performer.tsx @@ -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,27 +100,13 @@ export const Performer: FunctionComponent = (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); + ImageUtils.addPasteImageHook(onImageLoad); - return () => window.removeEventListener("paste", pasteImage); - }); - useEffect(() => { var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = []; @@ -208,14 +195,7 @@ export const Performer: FunctionComponent = (props: IPerformerP } function onImageChange(event: React.FormEvent) { - 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) { diff --git a/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx index 737122f84..83882444a 100644 --- a/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx @@ -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 = (props: IProps) => { const [studioId, setStudioId] = useState(undefined); const [performerIds, setPerformerIds] = useState(undefined); const [tagIds, setTagIds] = useState(undefined); + const [coverImage, setCoverImage] = useState(undefined); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [deleteFile, setDeleteFile] = useState(false); const [deleteGenerated, setDeleteGenerated] = useState(true); + const [isCoverImageOpen, setIsCoverImageOpen] = useState(false); + const [coverImagePreview, setCoverImagePreview] = useState(undefined); + // Network state const [isLoading, setIsLoading] = useState(false); @@ -64,8 +72,11 @@ export const SceneEditPanel: FunctionComponent = (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 ; } // if (!!error) { return <>error...; } @@ -83,6 +94,7 @@ export const SceneEditPanel: FunctionComponent = (props: IProps) => { studio_id: studioId, performer_ids: performerIds, tag_ids: tagIds, + cover_image: coverImage, }; } @@ -166,6 +178,15 @@ export const SceneEditPanel: FunctionComponent = (props: IProps) => { ); } + function onImageLoad(this: FileReader) { + setCoverImagePreview(this.result as string); + setCoverImage(this.result as string); + } + + function onCoverImageChange(event: React.FormEvent) { + ImageUtils.onImageChange(event, onImageLoad); + } + return ( <> {renderDeleteAlert()} @@ -231,6 +252,18 @@ export const SceneEditPanel: FunctionComponent = (props: IProps) => { {renderMultiSelect("tags", tagIds)} + +
+ + + + + +
+