Feature/ add video 2 (#8)

* api point works

* upload api calls done, make better all components state changes, some asesthetic improvements

* loading screens for the add video proccess

* added and uploaded video now in the uiEvents for the Engine to listen and read

---------

Co-authored-by: wil <wil@storyteller.ai>
This commit is contained in:
wilandwil authored and GitHub committed 2024-09-12 16:23:22 -04:00
1 parent 87eaa60716
commit 2bb35f9560
17 files changed
+405 -162

No files matched your search

+11 -1
View File
@@ -142,26 +142,36 @@ export class MediaUploadApi extends ApiManager {
blob,
fileName,
uuid,
is_intermediate_system_file = true,
maybe_title,
maybe_visibility = Visibility.Public,
maybe_style_name,
maybe_scene_source_media_file_token,
maybe_trim_end_millis,
maybe_trim_start_millis,
}: {
blob: Blob;
fileName: string;
uuid: string;
is_intermediate_system_file?: boolean;
maybe_title?: string;
maybe_visibility?: Visibility;
maybe_style_name?: string;
maybe_scene_source_media_file_token?: string;
maybe_trim_end_millis?: number;
maybe_trim_start_millis?: number;
}): Promise<ApiResponse<string>> {
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/upload/new_video`;
const options: Record<string, string | number | undefined> = {
is_intermediate_system_file: "true",
is_intermediate_system_file: is_intermediate_system_file
? "true"
: "false",
maybe_title,
maybe_visibility: maybe_visibility?.toString(),
maybe_style_name,
maybe_scene_source_media_file_token,
maybe_trim_end_millis,
maybe_trim_start_millis,
};
return this.Upload({ endpoint, blob, fileName, uuid, options });
}
@@ -27,6 +27,7 @@ export interface MediaFile {
maybe_duration_millis: number | null;
maybe_style_name: null | string;
public_bucket_path: string;
public_bucket_url: string;
cover_image: {
maybe_cover_image_public_bucket_path: null | string;
default_cover: {
+2 -2
View File
@@ -54,8 +54,8 @@ export class Engine {
this.addImage(image);
});
uiEvents.onGetStagedVideo((video) => {
console.log("Engine got trim data: ", video.trimData);
this.addVideo(video.file);
console.log("Engine got video: " + video.url);
// this.addVideo(video.file);
});
// TODO: You may listen to all the image toolbar events here
@@ -0,0 +1,76 @@
import { useCallback } from "react";
import { Signal } from "@preact/signals-react";
import { v4 as uuidv4 } from "uuid";
import { MediaUploadApi, MediaFilesApi } from "~/Classes/ApiManager";
import { TrimData } from "./TrimmerPlaybar";
import { DialogAddMediaStatuses } from "./enums";
import { Button } from "~/components/ui";
import { dispatchUiEvents } from "~/signals";
export const ButtonSubmitAdd = ({
file,
trimData,
onStatusChanged,
}: {
file: File | null;
trimData: Signal<TrimData | undefined>;
onStatusChanged: (newStatus: DialogAddMediaStatuses) => void;
}) => {
const handleAdd = useCallback(async () => {
if (file && trimData.value) {
// setup payload
const payload = {
blob: file,
fileName: file.name,
uuid: uuidv4(),
is_intermediate_system_file: false,
maybe_trim_start_millis: Math.round(trimData.value.trimStartMs),
maybe_trim_end_millis: Math.round(trimData.value.trimEndMs),
};
console.log("Add Video Payload >> ", payload);
// upload the file
onStatusChanged(DialogAddMediaStatuses.FILE_UPLOADING);
const meaidaUpload = new MediaUploadApi();
const fileUploadResponse = await meaidaUpload.UploadNewVideo(payload);
console.log("Add Video Response >> ", fileUploadResponse);
// if upload fails
if (!fileUploadResponse.success || !fileUploadResponse.data) {
onStatusChanged(DialogAddMediaStatuses.ERROR_FILE_UPLOAD);
return;
}
// request the file record
onStatusChanged(DialogAddMediaStatuses.FILE_RECORD_REQUESTING);
const mediaFiles = new MediaFilesApi();
const recordRequestResponse = await mediaFiles.GetMediaFileByToken({
mediaFileToken: fileUploadResponse.data,
});
// if reqest fails
if (!recordRequestResponse.success || !recordRequestResponse.data) {
onStatusChanged(DialogAddMediaStatuses.ERROR_FILE_RECORD_REQUEST);
return;
}
// return the good result
onStatusChanged(DialogAddMediaStatuses.FILE_RECORD_RECEIVED);
console.log(recordRequestResponse);
dispatchUiEvents.addVideoToEngine({
url: recordRequestResponse.data.public_bucket_url,
});
}
}, [file]);
return (
<Button
onClick={handleAdd}
disabled={file === null || trimData === undefined}
>
Add Video
</Button>
);
};
@@ -0,0 +1,102 @@
import { useCallback, useState } from "react";
import { signal } from "@preact/signals-react";
import { twMerge } from "tailwind-merge";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import {
dialogBackgroundStyles,
paperWrapperStyles,
} from "~/components/styles";
import { Button } from "~/components/ui";
import { QuickTrimVideoUploader, TrimData } from "./QuickTrimVideoUploader";
import { ButtonSubmitAdd } from "./ButtonSumbitAdd";
import { DialogAddMediaStatuses } from "./enums";
import { LoadingScreens } from "./LoadingScreens";
// import { useRenderCounter } from "~/hooks/useRenderCounter";
const initialState = {
file: null,
dialogStatus: DialogAddMediaStatuses.STAGING_FILE,
};
export const DialogAddVideo = ({
isOpen,
closeCallback,
}: {
isOpen: boolean;
closeCallback: () => void;
}) => {
// useRenderCounter("DialogAddVideo");
const [{ file, dialogStatus }, setStates] = useState<{
file: File | null;
dialogStatus: DialogAddMediaStatuses;
}>(initialState);
const trimData = signal<TrimData | undefined>(undefined);
const handleClose = useCallback(() => {
closeCallback();
}, [closeCallback]);
const changeDialogStatus = useCallback(
(newStatus: DialogAddMediaStatuses) => {
setStates((curr) => ({ ...curr, dialogStatus: newStatus }));
if (newStatus === DialogAddMediaStatuses.FILE_RECORD_RECEIVED) {
setTimeout(handleClose, 1000);
setTimeout(() => setStates(initialState), 1100);
}
},
[handleClose],
);
return (
<Dialog
open={isOpen}
onClose={closeCallback}
unmount={true}
className="relative z-50"
>
<div className={dialogBackgroundStyles}>
<DialogPanel className="w-full max-w-5xl">
<div
className={twMerge(
paperWrapperStyles,
"flex w-full max-w-5xl flex-col gap-4 px-6 py-4",
)}
style={{ height: "calc(100vh - 200px)" }}
>
<DialogTitle className="font-bold">Upload Video</DialogTitle>
{dialogStatus === DialogAddMediaStatuses.STAGING_FILE && (
<>
<QuickTrimVideoUploader
file={file}
onFileStaged={(file) => {
setStates((curr) => ({ ...curr, file }));
}}
trimData={trimData}
onTrimChange={(newTrimData: TrimData) => {
trimData.value = newTrimData;
}}
/>
<span className="grow" />
<div className="flex w-full justify-end gap-4">
<Button onClick={handleClose} variant="secondary">
Cancel
</Button>
<ButtonSubmitAdd
file={file}
trimData={trimData}
onStatusChanged={changeDialogStatus}
/>
</div>
</>
)}
<LoadingScreens currStatus={dialogStatus} />
</div>
</DialogPanel>
</div>
</Dialog>
);
};
@@ -0,0 +1,72 @@
import { LoadingSpinner } from "~/components/ui";
import { faCircleCheck } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { DialogAddMediaStatuses } from "./enums";
import { Button } from "~/components/ui";
export const LoadingScreens = ({
currStatus,
}: {
currStatus: DialogAddMediaStatuses;
}) => {
if (currStatus === DialogAddMediaStatuses.FILE_UPLOADING) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner isShowing={true} message="Uploading File..." />
</div>
);
}
if (currStatus === DialogAddMediaStatuses.FILE_RECORD_REQUESTING) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner isShowing={true} message="Processing File..." />
</div>
);
}
if (currStatus === DialogAddMediaStatuses.ERROR_FILE_UPLOAD) {
return (
<ErrorScreens
title="Fail to Upload File"
message="Your video maybe too long, the file maybe too big, or it maybe malformed. Please try again."
/>
);
}
if (currStatus === DialogAddMediaStatuses.ERROR_FILE_RECORD_REQUEST) {
return (
<ErrorScreens
title="Fail to Process File"
message="Your video maybe too long, the file maybe too big, or it maybe malformed. Please try again."
/>
);
}
if (currStatus === DialogAddMediaStatuses.FILE_RECORD_RECEIVED) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
<FontAwesomeIcon
icon={faCircleCheck}
className="size-10 text-green-500"
/>
<p>Upload and proccessing is successful!</p>
</div>
);
}
return null;
};
const ErrorScreens = ({
title,
message,
retry,
}: {
title: string;
message: string;
retry?: () => void;
}) => {
return (
<div>
<h2>{title}</h2>
<p>{message}</p>
<Button onClick={retry}>Retry</Button>
</div>
);
};
@@ -1,49 +0,0 @@
import { useCallback, useState } from "react";
import { VideoControls } from "./VideoControls";
import { TrimmerPlaybar, TrimData } from "./TrimmerPlaybar";
export type { TrimData };
export const QuickTrimVideoPlayer = ({
file,
onTrimChange,
}: {
file: File;
onTrimChange: (trimData: TrimData) => void;
}) => {
const [vidEl, setVidEl] = useState<HTMLVideoElement | undefined>(undefined);
const videoRefCallback = useCallback(
(node: HTMLVideoElement) => {
if (node !== null) {
// DOM node referenced by ref has changed and exists
setVidEl(node);
}
},
[
// No Dependency !
],
); //END videoRefCallback\
return (
<>
<div
className="relative w-full bg-black"
style={{ height: "calc(100vh - 500px)" }}
>
<video
className="max-w-8/12 mx-auto max-h-full w-full"
ref={videoRefCallback}
src={URL.createObjectURL(file)}
/>
</div>
<div className="flex items-center justify-center bg-gray-100">
<VideoControls vidEl={vidEl} className="w-fit" />
<TrimmerPlaybar
vidEl={vidEl}
className="grow"
onTrimChange={onTrimChange}
/>
</div>
</>
);
};
@@ -0,0 +1,70 @@
import { useCallback, useState } from "react";
import { Signal } from "@preact/signals-react";
import { FileUploader, VIDEO_FILE_TYPE } from "../FileUploader";
import { VideoControls } from "./VideoControls";
import { TrimmerPlaybar, TrimData } from "./TrimmerPlaybar";
export type { TrimData };
export const QuickTrimVideoUploader = ({
file,
onFileStaged,
onTrimChange,
trimData,
}: {
file: File | null;
onFileStaged: (newFile: File | null) => void;
onTrimChange: (trimData: TrimData) => void;
trimData?: Signal<TrimData | undefined>;
}) => {
const [vidEl, setVidEl] = useState<HTMLVideoElement | undefined>(undefined);
const videoRefCallback = useCallback(
(node: HTMLVideoElement) => {
if (node !== null) {
// DOM node referenced by ref has changed and exists
setVidEl(node);
}
},
[
// No Dependency !
],
); //END videoRefCallback\
return (
<div className="flex flex-col rounded-lg border-2 border-dashed border-ui-border">
<FileUploader
title=""
fileTypes={Object.values(VIDEO_FILE_TYPE)}
file={file}
setFile={async (file: File | null) => {
onFileStaged(file);
}}
/>
{file && (
<>
<div
className="relative w-full bg-black"
style={{ height: "calc(100vh - 500px)" }}
>
<video
className="max-w-8/12 mx-auto max-h-full w-full"
ref={videoRefCallback}
src={URL.createObjectURL(file)}
/>
</div>
<div className="flex items-center justify-center bg-gray-100">
<VideoControls vidEl={vidEl} className="w-fit" />
<TrimmerPlaybar
trimData={trimData?.value}
vidEl={vidEl}
className="grow"
onTrimChange={onTrimChange}
/>
</div>
</>
)}
</div>
);
};
@@ -14,10 +14,12 @@ import { TrimData } from "./utilities";
export const TrimmerPlaybarCore = ({
vidEl,
trimData,
className,
onTrimChange,
}: {
vidEl: HTMLVideoElement;
trimData?: TrimData;
className?: string;
onTrimChange: (trimData: TrimData) => void;
}) => {
@@ -84,9 +86,10 @@ export const TrimmerPlaybarCore = ({
...prev,
durationMs: vidEl.duration * 1000,
currentTimeMs: vidEl.currentTime * 1000,
trimStartMs: 0,
trimEndMs:
vidEl.duration * 1000 >= MAX_TRIM_DURATION
trimStartMs: trimData?.trimStartMs ?? 0,
trimEndMs: trimData?.trimEndMs
? trimData.trimEndMs
: vidEl.duration * 1000 >= MAX_TRIM_DURATION
? MAX_TRIM_DURATION
: vidEl.duration * 1000,
}));
@@ -102,8 +105,8 @@ export const TrimmerPlaybarCore = ({
vidEl.addEventListener("loadedmetadata", handleLoadedmetadata);
vidEl.addEventListener("timeupdate", handleTimeupdate);
return () => {
vidEl.removeEventListener("loadedmetadata", handleLoadedmetadata);
vidEl.removeEventListener("timeupdate", handleTimeupdate);
vidEl.removeEventListener("loadedmetadata", handleLoadedmetadata);
};
}, [vidEl]);
@@ -4,11 +4,13 @@ import { TrimData } from "./utilities";
export type { TrimData };
export const TrimmerPlaybar = ({
vidEl,
trimData,
className,
onTrimChange,
}: {
vidEl: HTMLVideoElement | undefined;
className?: string;
trimData?: TrimData;
onTrimChange: (trimData: TrimData) => void;
}) => {
if (!vidEl) {
@@ -18,6 +20,7 @@ export const TrimmerPlaybar = ({
<TrimmerPlaybarCore
vidEl={vidEl}
className={className}
trimData={trimData}
onTrimChange={onTrimChange}
/>
);
@@ -12,7 +12,7 @@ export type TrimData = {
export function formatSecondsToHHMMSSCS(seconds: number) {
//example of the ISO String: 1970-01-01T00:01:40.774Z
const isoString = new Date(seconds * 1000).toISOString();
const isoString = new Date(Math.round(seconds * 1000)).toISOString();
if (seconds > 3600)
return isoString.substring(11, 19) + "." + isoString.substring(20, 22);
else return isoString.substring(14, 19) + "." + isoString.substring(20, 22);
@@ -1,13 +1,32 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
export function LabelTimeDuration({ vidEl }: { vidEl: HTMLVideoElement }) {
const [currentTime, setCurrentTime] = useState<number>(0);
const safeGetDuration = useCallback(() => {
return typeof vidEl.duration === "number" ? vidEl.duration : 0;
}, [vidEl]);
const [{ currentTime, duration }, setState] = useState<{
currentTime: number;
duration: number;
}>({ currentTime: 0, duration: safeGetDuration() });
const setCurrentTime = useCallback((newCurrentTime: number) => {
setState((curr) => ({ ...curr, currentTime: newCurrentTime }));
}, []);
useEffect(() => {
const handleTimeStamp = () => setCurrentTime(vidEl.currentTime || 0);
const handleLoadedmetadata = () => {
setState((curr) => ({
...curr,
duration: safeGetDuration(),
}));
};
vidEl.addEventListener("timeupdate", handleTimeStamp);
vidEl.addEventListener("loadedmetadata", handleLoadedmetadata);
return () => {
vidEl.removeEventListener("loadedmetadata", handleLoadedmetadata);
vidEl.removeEventListener("timeupdate", handleTimeStamp);
};
}, [vidEl]);
@@ -16,13 +35,13 @@ export function LabelTimeDuration({ vidEl }: { vidEl: HTMLVideoElement }) {
<div className="flex items-center gap-1">
<p className="w-12">{`${formatSecondsToHHMMSSCS(currentTime)}`}</p>
<span>/</span>
<p className="w-12">{`${formatSecondsToHHMMSSCS(vidEl.duration)}`}</p>
<p className="w-12">{`${formatSecondsToHHMMSSCS(duration)}`}</p>
</div>
);
}
function formatSecondsToHHMMSSCS(seconds: number) {
//example of the ISO String: 1970-01-01T00:01:40.774Z
const isoString = new Date(seconds * 1000).toISOString();
const isoString = new Date(Math.round(seconds * 1000)).toISOString();
if (seconds > 3600)
return isoString.substring(11, 19) + "." + isoString.substring(20, 22);
else return isoString.substring(14, 19) + "." + isoString.substring(20, 22);
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { VIDEO_STATE_STATUSES } from "./enum";
@@ -14,32 +14,43 @@ export const VideoControls = ({
vidEl: HTMLVideoElement | undefined;
className?: string;
}) => {
const prevVidEl = useRef<HTMLVideoElement | undefined>(undefined);
const [status, setStatus] = useState<VIDEO_STATE_STATUSES>(
VIDEO_STATE_STATUSES.INIT,
);
useEffect(() => {
// helper function for removing listenders
const handleLoadedmetadata = () => {
if (vidEl) {
setStatus(VIDEO_STATE_STATUSES.METADATA_LOADED);
prevVidEl.current = vidEl;
}
};
// DOM node referencs has changed and exists
if (vidEl) {
vidEl.addEventListener("loadedmetadata", handleLoadedmetadata);
}
// DOM node referencs has changed
setStatus(VIDEO_STATE_STATUSES.INIT);
// if the node exist, attach the listener and its remover
vidEl?.addEventListener("loadedmetadata", handleLoadedmetadata);
return () => {
vidEl?.removeEventListener("loadedmetadata", handleLoadedmetadata);
};
}, [vidEl]);
useEffect(() => {
prevVidEl.current = vidEl;
}, []);
const wrapperClass = twMerge(
"flex w-full h-10 justify-center items-center gap-2",
className,
);
if (status === VIDEO_STATE_STATUSES.METADATA_LOADED && vidEl) {
if (
status === VIDEO_STATE_STATUSES.METADATA_LOADED &&
vidEl &&
prevVidEl.current === vidEl
) {
return (
<div className={wrapperClass}>
<ButtonPlaypause vidEl={vidEl} />
@@ -0,0 +1,9 @@
export enum DialogAddMediaStatuses {
STAGING_FILE = "STAGING_FILE",
FILE_UPLOADING = "FILE_UPLOADING",
FILE_UPLOADED = "FILE_UPLOADED",
ERROR_FILE_UPLOAD = "ERROR_FILE_UPLOAD",
FILE_RECORD_REQUESTING = "FILE_RECORD_REQUESTING",
FILE_RECORD_RECEIVED = "FILE_RECORD_RECEIVED",
ERROR_FILE_RECORD_REQUEST = "ERROR_FILE_RECORD_REQUEST",
}
@@ -1,77 +1 @@
import { useState, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import { FileUploader, VIDEO_FILE_TYPE } from "../FileUploader";
import { Button } from "~/components/ui";
import {
dialogBackgroundStyles,
paperWrapperStyles,
} from "~/components/styles";
import { dispatchUiEvents } from "~/signals/uiEvents";
import { QuickTrimVideoPlayer, TrimData } from "./QuickTrimVideoPlayer";
export const DialogAddVideo = ({
isOpen,
closeCallback,
}: {
isOpen: boolean;
closeCallback: () => void;
}) => {
const [assetFile, setAssetFile] = useState<File | null>(null);
const trimDataRef = useRef<TrimData | undefined>(undefined);
function handleClose() {
setAssetFile(null);
closeCallback();
}
function handleEnter() {
if (assetFile) {
dispatchUiEvents.addVideoToEngine(assetFile, trimDataRef.current);
}
handleClose();
}
return (
<Dialog open={isOpen} onClose={closeCallback} className="relative z-50">
<div className={dialogBackgroundStyles}>
<DialogPanel
className={twMerge(
paperWrapperStyles,
"flex w-full max-w-5xl flex-col gap-4 px-6 py-4",
)}
>
<DialogTitle className="font-bold">Upload Video</DialogTitle>
<div className="flex flex-col rounded-lg border-2 border-dashed border-ui-border">
<FileUploader
title=""
fileTypes={Object.values(VIDEO_FILE_TYPE)}
file={assetFile}
setFile={async (file: File | null) => {
setAssetFile(file);
}}
/>
{assetFile && (
<QuickTrimVideoPlayer
file={assetFile}
onTrimChange={(trimData) => {
trimDataRef.current = trimData;
}}
/>
)}
</div>
<div className="flex w-full justify-end gap-4">
<Button onClick={handleClose} variant="secondary">
Cancel
</Button>
<Button onClick={handleEnter} disabled={assetFile === null}>
Enter
</Button>
</div>
</DialogPanel>
</div>
</Dialog>
);
};
export * from "./DialogAddVideo";
@@ -22,7 +22,7 @@ export const DragAndDropZone = ({ file, fileTypes }: Props) => {
? `${Math.floor(file.size / 1024)} KB`
: null;
const fileName = file && getFileName(file).toUpperCase();
// const fileName = file && getFileName(file).toUpperCase();
const wrapperClassName = twMerge(
"group cursor-pointer p-3 bg-gray-100",
!file && "aspect-video flex flex-col items-center justify-center gap-6",
@@ -54,15 +54,13 @@ export const DragAndDropZone = ({ file, fileTypes }: Props) => {
<p className="font-medium">
{file.name.slice(0, file.name.lastIndexOf("."))}
</p>
<p className="flex items-center gap-2 text-sm font-normal">
<span className="opacity-50">
{`${fileName} file size: ${fileSize} `}
</span>
<u className="transition-all group-hover:text-primary">
Change File
</u>
<p className="flex items-center gap-2 text-sm font-normal text-gray-500">
{`file size: ${fileSize} `}
</p>
</div>
<div className="rounded-md bg-primary px-4 py-2 hover:bg-primary-400">
<p className="font-normal text-white">Change File</p>
</div>
</div>
);
}
+4 -10
View File
@@ -1,5 +1,4 @@
import { signal, effect } from "@preact/signals-react";
import { TrimData } from "~/components/features/DialogAddVideo/TrimmerPlaybar";
const stagedImage = signal<File | null>(null);
@@ -15,18 +14,13 @@ const onGetStagedImage = (callback: (file: File) => void) => {
});
};
const stagedVideo = signal<{ file: File; trimData?: TrimData } | null>(null);
const stagedVideo = signal<{ url: string } | null>(null);
const addVideoToEngine = (file: File, trimData?: TrimData) => {
stagedVideo.value = { file, trimData };
const addVideoToEngine = (videoData: { url: string }) => {
stagedVideo.value = videoData;
};
const onGetStagedVideo = (
callback: (videoData: {
file: File;
trimData?: { trimStartMs: number; trimEndMs: number };
}) => void,
) => {
const onGetStagedVideo = (callback: (videoData: { url: string }) => void) => {
effect(() => {
if (stagedVideo.value) {
callback(stagedVideo.value);