added cropping to EntityInput, and ZoomSlider

This commit is contained in:
Victor committed 2024-07-25 17:22:55 -07:00
1 parent f09ed11c6e
commit 1e983d7b91
14 files changed
+562 -93

No files matched your search

+1
View File
@@ -36,6 +36,7 @@
"framer-motion": "6.5.1",
"local-storage": "^2.0.0",
"moment": "^2.29.4",
"react-easy-crop": "^5.0.7",
"universal-cookie": "^6.1.1",
"yarn": "^1.22.19"
},
@@ -1,24 +0,0 @@
import MakeRequest from "../MakeRequest";
export interface EnqueueActingFaceRequest {
creator_set_visibility: "public" | "private";
face_driver_media_file_token: string;
remove_watermark: boolean;
source_media_file_token: string;
uuid_idempotency_token: string;
}
export interface EnqueueActingFaceResponse {
inference_job_token?: string;
success: boolean;
}
export const EnqueueActingFace = MakeRequest<
string,
EnqueueActingFaceRequest,
EnqueueActingFaceResponse,
{}
>({
method: "POST",
routingFunction: () => `/v1/workflows/enqueue_acting_face`,
});
@@ -0,0 +1,33 @@
import MakeRequest from "../MakeRequest";
// this type is eqivelent to react-easy-crops Area type
export interface MediaFileCropArea {
height: number;
width: number;
x: number;
y: number;
}
export interface EnqueueFaceMirrorRequest {
creator_set_visibility: "public" | "private";
face_driver_media_file_token: string;
maybe_crop: MediaFileCropArea;
remove_watermark: boolean;
source_media_file_token: string;
uuid_idempotency_token: string;
}
export interface EnqueueFaceMirrorResponse {
inference_job_token?: string;
success: boolean;
}
export const EnqueueFaceMirror = MakeRequest<
string,
EnqueueFaceMirrorRequest,
EnqueueFaceMirrorResponse,
{}
>({
method: "POST",
routingFunction: () => `/v1/workflows/enqueue_face_mirror`,
});
@@ -0,0 +1,71 @@
import React from "react";
import { Direction, Range } from "react-range";
import { FontAwesomeIcon as Icon } from "@fortawesome/react-fontawesome";
import {
faMagnifyingGlassPlus,
faMagnifyingGlassMinus,
} from "@fortawesome/pro-solid-svg-icons";
export interface ZoomSliderOnChangeEvent {
target: { name?: string; type: "zoom"; value: number };
}
interface Props {
name?: string;
onChange: (event: ZoomSliderOnChangeEvent) => void;
value: number;
}
const renderTrack = ({ props: { style, ...props }, children }: any) => (
<div {...{ ...props, className: "fy-zoom-slider-track", style }}>
{children}
<div {...{ className: "fy-zoom-slider-track-bar" }}></div>
</div>
);
const thumb =
(thumbTip = "") =>
({ props: { style, ...props } }: any) => {
return (
<div {...{ ...props, className: "fy-zoom-slider-thumb", style }}>
{thumbTip}
</div>
);
};
export default function ZoomSlider({
name = "",
onChange: inChange,
value,
}: Props) {
const onChange = (newVal: number[]) =>
inChange({
target: { ...(name && { name }), type: "zoom", value: newVal[0] },
});
return (
<div {...{ className: "fy-zoom-slider" }}>
<Icon {...{ icon: faMagnifyingGlassPlus }} />
<div {...{ className: "fy-zoom-slider-container" }}>
<Range
{...{
direction: Direction.Up,
min: 1,
max: 3,
onChange,
step: 0.01,
renderThumb: thumb(
Math.round(((value - 1) / 2) * 100).toString() + "%"
),
renderTrack,
// renderTrack,
// step: step,
// thumbTip,
values: [value],
}}
/>
</div>
<Icon {...{ icon: faMagnifyingGlassMinus }} />
</div>
);
}
@@ -0,0 +1,2 @@
export { default } from "./ZoomSlider";
export type { ZoomSliderOnChangeEvent } from "./ZoomSlider";
@@ -59,6 +59,7 @@ import VideoBasic from "./VideoBasic";
import VideoFakeyou from "./VideoFakeyou";
import VideoInput from "./VideoInput";
import WeightCoverImage from "./WeightCoverImage";
import ZoomSlider, { ZoomSliderOnChangeEvent } from "./ZoomSlider";
export {
Accordion,
@@ -127,6 +128,13 @@ export {
VideoFakeyou,
VideoInput,
WeightCoverImage,
ZoomSlider,
};
export type { ActionButtonProps, ModalUtilities, SelectModalData, Socials };
export type {
ActionButtonProps,
ModalUtilities,
SelectModalData,
Socials,
ZoomSliderOnChangeEvent,
};
@@ -13,6 +13,47 @@
overflow: hidden;
position: relative;
// background-color: $panel;
.fy-entity-input-crop-container-hidden {
cursor: default;
background-color: rgba($secondary-very-dark, 0.75);
.fy-entity-input-crop-area {
color: rgba(#f00, 0);
}
}
.fy-entity-input-crop-area {
color: rgba($secondary-very-dark, 0.75);
}
.fy-entity-input-crop-media {
box-shadow:
// 0 0 0 1px rgba(#000, 0.16),
0 0 15px rgb(#000, 0.5);
}
.fy-entity-input-play-button {
border: none;
background-color: rgba($secondary-very-dark, 0.5);
backdrop-filter: blur(5px);
left: 0;
right: 0;
bottom: 1rem;
margin: auto;
width: fit-content;
// background-color: red;
position: absolute;
}
.fy-entity-input-crop-tip {
display: none;
position: absolute;
background-color: rgba($secondary-very-dark, 0.5);
padding: 0.5rem;
border-radius: 0.5rem;
color: rgba(#fff, 0.5);
}
}
.fy-entity-input-square {
@@ -31,7 +72,8 @@
.fy-entity-input-empty,
.fy-entity-input-full {
// position: relative;
// background-color: red;
position: relative;
// overflow: hidden;
justify-content: center;
@@ -49,6 +91,9 @@
}
.fy-entity-input-file-details {
padding: 0 0.5rem;
// background-color: red;
// display: inline-flex;
div {
font-size: 0.75rem;
}
@@ -72,33 +117,67 @@
}
.fy-entity-input-full {
display: flex;
display: grid;
grid-template-columns: 1fr fit-content(50%);
// grid-template-rows: 1fr 1fr;
// display: flex;
video {
width: 100%;
}
// video {
// width: 100%;
// }
}
.fy-entity-input-full-controls {
display: flex;
align-items: center;
justify-content: space-between;
position: absolute;
.fy-entity-input-media-preview {
// display: inline-block;
position: relative;
// width: 66%;
// height: 100%;
// width: 100%;
// aspect-ratio: 1;
padding: 0.5rem;
}
.fy-entity-input-preview-controls {
display: grid;
// background-color: red;
background-color: rgba(#000, 0.5);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.1) 6.25%,
rgba(0, 0, 0, 0.25) 12.5%,
rgba(0, 0, 0, 0.4) 18.75%,
rgba(0, 0, 0, 0.5) 25%
);
grid-template-rows: 1fr auto;
gap: 0.5rem;
padding: 1rem;
}
.fy-entity-input-preview-tools {
display: grid;
grid-template-columns: auto 1fr;
// grid-template-rows: 1fr auto;
gap: 0.5rem;
// display: inline-block;
// width: 33%;
// height: 100%;
background-color: $secondary-dark;
// display: flex;
// align-items: center;
// justify-content: space-between;
// position: absolute;
// background-color: red;
// background-color: rgba(#000, 0.5);
// background: linear-gradient(
// 0deg,
// rgba(0, 0, 0, 0) 0%,
// rgba(0, 0, 0, 0.1) 6.25%,
// rgba(0, 0, 0, 0.25) 12.5%,
// rgba(0, 0, 0, 0.4) 18.75%,
// rgba(0, 0, 0, 0.5) 25%
// );
// padding: 1rem;
// backdrop-filter: blur(2px);
overflow: hidden;
border-radius: 0.5rem 0.5rem 0 0;
width: 100%;
// width: 100%;
.fy-zoom-slider {
padding: 0.5rem 0;
}
}
.fy-entity-input-empty-controls {
@@ -146,3 +225,69 @@
}
}
}
.fy-zoom-slider {
display: flex;
flex-direction: column;
height: 100%;
// padding: 1rem 0;
svg {
color: rgba(#fff, 0.25);
}
}
.fy-zoom-slider-container {
height: 100%;
// background-color: red;
margin: 1.5rem 0;
// width: 3rem;
}
.fy-zoom-slider-track {
display: inline-flex;
justify-content: center;
height: 100%;
width: 2.25rem;
// background-color: green;
.fy-zoom-slider-track-bar {
background-color: $secondary-darker;
border-radius: 0.25rem;
// margin: 1rem 0;
// flex-shrink: 1;
box-shadow:
// inset 0 0 1px 1px rgba(#000, 0.16),
inset 0 5px 10px rgb(#000, 0.25);
border-bottom: 1px solid rgba(#fff, 0.07);
// border-left: none;
// border-right: none;
width: 0.5rem;
height: 100%;
}
}
.fy-zoom-slider-thumb {
color: rgba(#fff, 0.6);
border: 1px solid rgba(#fff, 0.04);
border-left: none;
border-right: none;
box-shadow:
// 0 0 0 1px rgba(#000, 0.16),
0 5px 10px rgb(#000, 0.25);
// @include panel;
display: flex;
justify-content: center;
align-items: center;
font-size: 0.75rem;
background-color: $secondary;
border-radius: 0.75rem;
height: 2.25rem;
width: 2.25rem;
&:focus-visible {
// color: $secondary;
background-color: $secondary-light;
outline: none;
}
}
@@ -3,7 +3,8 @@ import { useLocation } from "react-router-dom";
import { a, useTransition } from "@react-spring/web";
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMedia";
import Iframe from "react-iframe";
import { Button, Label, Spinner } from "components/common";
import { Area, Point } from "react-easy-crop";
import { Label, Spinner, ZoomSliderOnChangeEvent } from "components/common";
import {
AcceptTypes,
EntityModeProp,
@@ -15,12 +16,25 @@ import { useMedia, useMediaUploader } from "hooks";
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import { Prompt } from "@storyteller/components/src/api/prompts/GetPrompts";
import EntityInputEmpty from "./EntityInputEmpty";
import EntityInputSidePanel from "./EntityInputSidePanel";
import EntityInputImageVideoPreview from "./EntityInputImageVideoPreview";
import "./EntityInput.scss";
export type OnCropComplete = (
croppedArea: Area,
croppedAreaPixels: Area
) => void;
export interface CropProps {
aspect: number;
onCropComplete?: OnCropComplete;
}
interface EntityInputProps {
accept?: AcceptTypes | AcceptTypes[];
aspectRatio?: "square" | "landscape" | "portrait";
className?: string;
cropProps?: CropProps;
debug?: string;
label?: string;
name?: string;
@@ -31,14 +45,16 @@ interface EntityInputProps {
}
export interface SlideProps {
media?: MediaFile;
clear: () => void;
cropProps?: CropProps;
media?: MediaFile;
}
interface AniProps {
animating: boolean;
className: string;
isLeaving: boolean;
onCropComplete?: OnCropComplete;
render: any;
style: any;
}
@@ -47,53 +63,68 @@ const MediaBusy = () => {
return <Spinner />;
};
const EntityInputFull = ({ media, clear }: SlideProps) => {
const EntityInputFull = ({ clear, cropProps, media }: SlideProps) => {
const bucketConfig = new BucketConfig();
const mediaUrl = media?.public_bucket_path
? bucketConfig.getGcsUrl(media.public_bucket_path)
: "";
const mediaType = mediaCategoryfromString(media?.media_type || "");
const uploader = `Uploaded by ${
media?.maybe_creator_user?.display_name || "User"
}`;
const [crop, cropSet] = useState<Point>({ x: 0, y: 0 });
const [zoom, zoomSet] = useState(1);
const zoomSliderChange = ({ target }: ZoomSliderOnChangeEvent) =>
zoomSet(target.value);
switch (mediaType) {
case MediaFilters.image:
return (
<>
<img {...{ src: mediaUrl, alt: "Selected media file" }} />
<div {...{ className: "fy-entity-input-full-controls" }}>
<div {...{ className: "fy-entity-input-file-details" }}>
{media?.maybe_title || "Untitled image"}
<div>{uploader}</div>
</div>
<Button
{...{
label: "Clear",
variant: "secondary",
onClick: () => clear(),
}}
/>
</div>
<EntityInputImageVideoPreview
{...{
crop,
cropProps,
cropSet,
image: mediaUrl,
zoom,
zoomSet,
}}
/>
<EntityInputSidePanel
{...{
clear,
entityType: "image",
media,
showCrop: !!cropProps,
zoomSliderChange,
zoom,
}}
/>
</>
);
case MediaFilters.video:
return (
<>
<video controls {...{ src: mediaUrl }} />
<div {...{ className: "fy-entity-input-full-controls" }}>
<div {...{ className: "fy-entity-input-file-details" }}>
{media?.maybe_title || "Untitled video"}
<div>{uploader}</div>
</div>
<Button
{...{
label: "Clear",
variant: "secondary",
onClick: () => clear(),
}}
/>
</div>
<EntityInputImageVideoPreview
{...{
crop,
cropProps,
cropSet,
video: mediaUrl,
zoom,
zoomSet,
}}
/>
<EntityInputSidePanel
{...{
clear,
entityType: "video",
media,
showCrop: !!cropProps,
zoomSliderChange,
zoom,
}}
/>
</>
);
case MediaFilters.engine_asset:
@@ -105,7 +136,7 @@ const EntityInputFull = ({ media, clear }: SlideProps) => {
className: "fy-entity-input-mocap-preview",
}}
/>
<div {...{ className: "fy-entity-input-full-controls" }}>
<div {...{ className: ".fy-entity-input-preview-info" }}>
Your file
</div>
</>
@@ -137,6 +168,7 @@ export default function EntityInput({
accept,
aspectRatio = "square",
className,
cropProps,
debug,
label,
name = "",
@@ -250,6 +282,7 @@ export default function EntityInput({
{...{
className: "fy-entity-input-full",
clear,
cropProps,
media,
render: EntityInputFull,
...sharedProps,
@@ -0,0 +1,101 @@
import React, { useState } from "react";
import Cropper, { Point } from "react-easy-crop";
import { Button } from "components/common";
import { useVideo } from "hooks";
import { faPlay, faPause } from "@fortawesome/free-solid-svg-icons";
import { CropProps } from "./EntityInput";
interface EntityInputImageVideoPreviewProps {
crop: Point;
cropProps?: CropProps;
cropSet: (crop: Point) => void;
image?: string;
video?: string;
zoom: number;
zoomSet: (zoom: number) => void;
}
export default function EntityInputImageVideoPreview({
crop,
cropProps,
cropSet,
image,
video,
zoom,
zoomSet,
}: EntityInputImageVideoPreviewProps) {
const [videoRef, videoRefSet] = useState<
React.RefObject<HTMLVideoElement> | undefined
>();
const [isPlaying, isPlayingSet] = useState(false);
const [{ playCtrl }, { onEnded }] = useVideo({
videoRef,
onEnded: (playPause: boolean) => isPlayingSet(!playPause),
});
return (
<div {...{ className: "fy-entity-input-media-preview" }}>
<Cropper
{...{
aspect: cropProps?.aspect || 1,
...(image ? { image } : {}),
classes: {
containerClassName: `fy-entity-input-crop-container${
cropProps ? "" : "-hidden"
}`,
cropAreaClassName: "fy-entity-input-crop-area",
mediaClassName: "fy-entity-input-crop-media",
},
crop,
mediaProps: {
autoPlay: false,
loop: false,
onEnded,
},
onCropChange: (cropLocation: Point) => {
if (cropProps) {
cropSet(cropLocation);
}
},
...(cropProps?.onCropComplete
? { onCropComplete: cropProps.onCropComplete }
: {}),
onZoomChange: (zoomValue: number) => {
if (cropProps) {
zoomSet(zoomValue);
}
},
setVideoRef: (ref: React.RefObject<HTMLVideoElement>) => {
videoRefSet(ref);
},
showGrid: !!cropProps,
style: {
cropAreaStyle: {
border: "none",
// color: "rgba(#f00,0.0)",
},
},
...(video ? { video } : {}),
zoom,
}}
/>
{video ? (
<Button
{...{
className: "fy-entity-input-play-button",
icon: isPlaying ? faPause : faPlay,
onClick: () => {
playCtrl!();
isPlayingSet(!isPlaying);
},
}}
/>
) : null}
{cropProps ? (
<div {...{ className: "fy-entity-input-crop-tip" }}>
Drag and scroll to crop
</div>
) : null}
</div>
);
}
@@ -0,0 +1,46 @@
import React from "react";
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMedia";
import { Button, ZoomSlider, ZoomSliderOnChangeEvent } from "components/common";
interface Props {
clear: () => void;
entityType: "image" | "video";
media?: MediaFile;
showCrop: boolean;
zoom: number;
zoomSliderChange: (zoom: ZoomSliderOnChangeEvent) => void;
}
export default function EntityInputSidePanel({
clear,
entityType,
media,
showCrop,
zoomSliderChange,
zoom,
}: Props) {
const uploader = `Uploaded by ${
media?.maybe_creator_user?.display_name || "User"
}`;
return (
<div {...{ className: "fy-entity-input-preview-controls" }}>
<div {...{ className: "fy-entity-input-preview-tools" }}>
{showCrop && (
<ZoomSlider {...{ onChange: zoomSliderChange, value: zoom }} />
)}
<div {...{ className: "fy-entity-input-file-details" }}>
{media?.maybe_title || `Untitled ${entityType}`}
<div>{uploader}</div>
</div>
</div>
<Button
{...{
label: `Choose another ${entityType}`,
variant: "secondary",
onClick: () => clear(),
}}
/>
</div>
);
}
@@ -1,14 +1,21 @@
import { useRef } from 'react';
import { useRef } from "react";
interface Props {
onEnded?: any;
videoRef?: React.RefObject<HTMLVideoElement>;
}
export default function useVideo({ onEnded }: Props) {
const ref = useRef<HTMLVideoElement>(null);
const playCtrl = (toDo = (b:any) => {}) => {
if (ref.current) {
let isPlaying = !!(ref.current.currentTime > 0 && !ref.current.paused && !ref.current.ended && ref.current.readyState > 2);
export default function useVideo({ onEnded, videoRef }: Props) {
const internalRef = useRef<HTMLVideoElement>(null);
const ref = videoRef || internalRef;
const playCtrl = (toDo = (b: any) => {}) => {
if (ref.current) {
let isPlaying = !!(
ref.current.currentTime > 0 &&
!ref.current.paused &&
!ref.current.ended &&
ref.current.readyState > 2
);
if (!isPlaying) {
toDo(false);
ref.current.play();
@@ -19,5 +26,5 @@ export default function useVideo({ onEnded }: Props) {
}
};
return [{ playCtrl },{ onEnded, ref }];
};
return [{ playCtrl }, { onEnded, ref }];
}
@@ -45,6 +45,9 @@ $primary-light: desaturate(lighten($primary, 20%), 15%);
$primary-dark: saturate(darken($primary, 15%), 10%);
$primary-very-light: desaturate(lighten($primary, 45%), 35%);
$secondary-dark: saturate(darken($secondary, 6%), 2%);
$secondary-darker: saturate(darken($secondary, 9%), 2%);
$secondary-very-dark: saturate(darken($secondary, 12%), 2%);
$secondary-light: lighten($secondary, 5%);
$secondary-lighter: lighten($secondary, 12%);
$secondary-very-light: lighten($secondary, 24%);
@@ -14,9 +14,10 @@ import {
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import { FrontendInferenceJobType } from "@storyteller/components/src/jobs/InferenceJob";
import {
EnqueueActingFace,
EnqueueActingFaceResponse,
} from "@storyteller/components/src/api/workflows/EnqueueActingFace";
MediaFileCropArea,
EnqueueFaceMirror,
EnqueueFaceMirrorResponse,
} from "@storyteller/components/src/api/workflows/EnqueueFaceMirror";
import { EntityInput } from "components/entities";
import {
useInferenceJobs,
@@ -37,6 +38,13 @@ export default function AIFaceMirror({ sessionSubscriptionsWrapper }: Props) {
"m_41caq6n7nw15y9e68009bgkn23m3yf"
);
const [visibility, visibilitySet] = useState<"private" | "public">("public");
const [cropArea, cropAreaSet] = useState<MediaFileCropArea>({
height: 0,
width: 0,
x: 0,
y: 0,
});
const { enqueue } = useInferenceJobs();
// const { canBanUsers } = useSession();
@@ -51,13 +59,14 @@ export default function AIFaceMirror({ sessionSubscriptionsWrapper }: Props) {
const enqueueClick = () => {
if (hasTokens) {
EnqueueActingFace("", {
EnqueueFaceMirror("", {
creator_set_visibility: visibility,
face_driver_media_file_token: faceDriverToken,
maybe_crop: cropArea,
remove_watermark: removeWatermark,
source_media_file_token: sourceMediaToken,
uuid_idempotency_token: uuidv4(),
}).then((res: EnqueueActingFaceResponse) => {
}).then((res: EnqueueFaceMirrorResponse) => {
if (res.success && res.inference_job_token) {
enqueue(
res.inference_job_token,
@@ -121,6 +130,12 @@ export default function AIFaceMirror({ sessionSubscriptionsWrapper }: Props) {
<EntityInput
{...{
accept: ["video"],
cropProps: {
aspect: 1,
onCropComplete: (croppedArea, croppedAreaPixels) => {
cropAreaSet(croppedAreaPixels);
},
},
// aspectRatio: "landscape",
// debug: "AFM driver input",
name: "faceDriverToken",
+28
View File
@@ -4486,6 +4486,7 @@ __metadata:
moment: ^2.29.4
prettier: ^3.1.0
pretty-quick: ^3.1.3
react-easy-crop: ^5.0.7
universal-cookie: ^6.1.1
yarn: ^1.22.19
languageName: unknown
@@ -15425,6 +15426,13 @@ __metadata:
languageName: node
linkType: hard
"normalize-wheel@npm:^1.0.1":
version: 1.0.1
resolution: "normalize-wheel@npm:1.0.1"
checksum: 00b32efa040bad9438e732385a4ca27f8532fa2c8c06b54be43b9f75b2da6642bf41a0b4f81e542639dc8d682cde8b059b0a02ae0723fb8ebc3c8f036c8e51d8
languageName: node
linkType: hard
"npm-bundled@npm:^1.1.1":
version: 1.1.2
resolution: "npm-bundled@npm:1.1.2"
@@ -17800,6 +17808,19 @@ __metadata:
languageName: node
linkType: hard
"react-easy-crop@npm:^5.0.7":
version: 5.0.7
resolution: "react-easy-crop@npm:5.0.7"
dependencies:
normalize-wheel: ^1.0.1
tslib: ^2.0.1
peerDependencies:
react: ">=16.4.0"
react-dom: ">=16.4.0"
checksum: 562ea94b8c938e842965cee05b9ab99d829ff89cbdf155d03a834800a930b5429be4ee62981dca111533c063bc630a6bb1d649a00024481f80426d7a1325ff79
languageName: node
linkType: hard
"react-error-overlay@npm:^6.0.10":
version: 6.0.10
resolution: "react-error-overlay@npm:6.0.10"
@@ -20751,6 +20772,13 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:^2.0.1":
version: 2.6.3
resolution: "tslib@npm:2.6.3"
checksum: 74fce0e100f1ebd95b8995fbbd0e6c91bdd8f4c35c00d4da62e285a3363aaa534de40a80db30ecfd388ed7c313c42d930ee0eaf108e8114214b180eec3dbe6f5
languageName: node
linkType: hard
"tslib@npm:^2.0.3":
version: 2.3.1
resolution: "tslib@npm:2.3.1"