diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/client/Network.ts | 3 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/ProgressBar.scss | 105 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/ProgressBar.tsx | 328 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingBox.tsx | 83 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingView.scss | 33 | ||||
-rw-r--r-- | src/client/views/nodes/RecordingBox/RecordingView.tsx | 147 | ||||
-rw-r--r-- | src/client/views/nodes/trails/PresElementBox.tsx | 4 | ||||
-rw-r--r-- | src/server/ApiManagers/UploadManager.ts | 13 | ||||
-rw-r--r-- | src/server/DashUploadUtils.ts | 45 |
9 files changed, 594 insertions, 167 deletions
diff --git a/src/client/Network.ts b/src/client/Network.ts index 3597e7b2b..b26f2458d 100644 --- a/src/client/Network.ts +++ b/src/client/Network.ts @@ -35,7 +35,7 @@ export namespace Networking { const response = await fetch("/uploadFormData", parameters); return response.json(); } - + export async function UploadYoutubeToServer<T extends Upload.FileInformation = Upload.FileInformation>(videoId: string): Promise<Upload.FileResponse<T>[]> { const parameters = { method: 'POST', @@ -45,5 +45,6 @@ export namespace Networking { const response = await fetch("/uploadYoutubeVideo", parameters); return response.json(); } + }
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.scss b/src/client/views/nodes/RecordingBox/ProgressBar.scss index a493b0b89..28ad25ffa 100644 --- a/src/client/views/nodes/RecordingBox/ProgressBar.scss +++ b/src/client/views/nodes/RecordingBox/ProgressBar.scss @@ -1,11 +1,19 @@ .progressbar { + touch-action: none; + vertical-align: middle; + text-align: center; + + align-items: center; + cursor: default; + + position: absolute; display: flex; justify-content: flex-start; - bottom: 10px; - width: 80%; - height: 5px; + bottom: 2px; + width: 99%; + height: 30px; background-color: gray; &.done { @@ -23,4 +31,93 @@ z-index: 3; pointer-events: none; } -}
\ No newline at end of file +} + +.progressbar-disabled { + cursor: not-allowed; +} + +.progressbar-dragging { + cursor: grabbing; +} + +// citation: https://codepen.io/_Master_/pen/PRdjmQ +@keyframes blinker { + from {opacity: 1.0;} + to {opacity: 0.0;} +} +.blink { + text-decoration: blink; + animation-name: blinker; + animation-duration: 0.6s; + animation-iteration-count:infinite; + animation-timing-function:ease-in-out; + animation-direction: alternate; +} + +.segment { + border: 3px solid black; + background-color: red; + margin: 1px; + padding: 0; + cursor: pointer; + transition-duration: .5s; + user-select: none; + + vertical-align: middle; + text-align: center; +} + +.segment-expanding { +border-color: red; + background-color: white; + transition-duration: 0s; + opacity: .75; + pointer-events: none; +} + +.segment-expanding:hover { + background-color: inherit; + cursor: not-allowed; +} + +.segment-disabled { + pointer-events: none; + opacity: 0.5; + transition-duration: 0s; + /* Hide the text. */ + text-indent: 100%; + white-space: nowrap; + overflow: hidden; +} + +.segment-hide { + background-color: inherit; + text-align: center; + vertical-align: middle; + user-select: none; +} + +.segment:first-child { + margin-left: 2px; +} +.segment:last-child { + margin-right: 2px; +} + +.segment:hover { + background-color: white; +} + +.segment:hover, .segment-selected { + margin: 0px; + border: 4px solid red; + border-radius: 2px; +} + +.segment-selected { + border: 4px solid #202020; + background-color: red; + opacity: .75; + cursor: grabbing; +} diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.tsx b/src/client/views/nodes/RecordingBox/ProgressBar.tsx index 82d5e1f04..dfe57db3f 100644 --- a/src/client/views/nodes/RecordingBox/ProgressBar.tsx +++ b/src/client/views/nodes/RecordingBox/ProgressBar.tsx @@ -1,45 +1,301 @@ import * as React from 'react'; -import { useEffect } from "react" +import { useEffect, useState, useCallback, useRef } from "react" import "./ProgressBar.scss" +import { MediaSegment } from './RecordingView'; interface ProgressBarProps { - progress: number, - marks: number[], + videos: MediaSegment[], + setVideos: React.Dispatch<React.SetStateAction<MediaSegment[]>>, + orderVideos: boolean, + progress: number, + recording: boolean, + doUndo: boolean, + setCanUndo?: React.Dispatch<React.SetStateAction<boolean>>, +} + +interface SegmentBox { + endTime: number, + startTime: number, + order: number, +} +interface CurrentHover { + index: number, + minX: number, + maxX: number } export function ProgressBar(props: ProgressBarProps) { + const progressBarRef = useRef<HTMLDivElement | null>(null) + + // the actual list of JSX elements rendered as segments + const [segments, setSegments] = useState<JSX.Element[]>([]); + // array for the order of video segments + const [ordered, setOrdered] = useState<SegmentBox[]>([]); + + const [undoStack, setUndoStack] = useState<SegmentBox[]>([]); + + // -1 if no segment is currently being dragged around; else, it is the id of that segment over + // NOTE: the id of a segment is its index in the ordered array + const [dragged, setDragged] = useState<number>(-1); + + // length of the time removed from the video, in seconds*100 + const [totalRemovedTime, setTotalRemovedTime] = useState<number>(0); + + // this holds the index of the videoc segment to be removed + const [removed, setRemoved] = useState<number>(-1); + + // update the canUndo props based on undo stack + useEffect(() => props.setCanUndo?.(undoStack.length > 0), [undoStack.length]); + + // useEffect for undo - brings back the most recently deleted segment + useEffect(() => handleUndo(), [props.doUndo]) + const handleUndo = () => { + // get the last element from the undo if it exists + if (undoStack.length === 0) return; + // get and remove the last element from the undo stack + const last = undoStack.lastElement(); + setUndoStack(prevUndo => prevUndo.slice(0, -1)); + + // update the removed time and place element back into ordered + setTotalRemovedTime(prevRemoved => prevRemoved - (last.endTime - last.startTime)); + setOrdered(prevOrdered => [...prevOrdered, last]); + } + + // useEffect for recording changes - changes style to disabled and adds the "expanding-segment" + useEffect(() => { + // get segments segment's html using it's id -> make them appeared disabled (or enabled) + segments.forEach((seg) => document.getElementById(seg.props.id)?.classList.toggle('segment-disabled', props.recording)); + progressBarRef.current?.classList.toggle('progressbar-disabled', props.recording); + + if (props.recording) + setSegments(prevSegments => [...prevSegments, <div key='segment-expanding' id='segment-expanding' className='segment segment-expanding blink' style={{ width: '0px;' }}>{props.videos.length + 1}</div>]); + }, [props.recording]) + + + // useEffect that updates the segmentsJSX, which is rendered + // only updated when ordered is updated or if the user is dragging around a segment + useEffect(() => { + const totalTime = props.progress * 1000 - totalRemovedTime; + const segmentsJSX = ordered.map((seg, i) => + <div key={`segment-${i}`} id={`segment-${i}`} className={dragged === i ? 'segment-hide' : 'segment'} style={{ width: `${((seg.endTime - seg.startTime) / totalTime) * 100}%` }}>{seg.order + 1}</div>); + + setSegments(segmentsJSX) + }, [dragged, ordered]); + + // useEffect for dragged - update the cursor to be grabbing while grabbing + useEffect(() => { + progressBarRef.current?.classList.toggle('progressbar-dragging', dragged !== -1); + }, [dragged]); + + // to imporve performance, only want to update the CSS width, not re-render the whole JSXList + useEffect(() => { + if (!props.recording) return + const totalTime = props.progress * 1000 - totalRemovedTime; + let remainingTime = totalTime; + segments.forEach((seg, i) => { + // for the last segment, we need to set that directly + if (i === segments.length - 1) return; + // update remaining time + remainingTime -= (ordered[i].endTime - ordered[i].startTime); + + // update the width for this segment + const htmlId = seg.props.id; + const segmentHtml = document.getElementById(htmlId); + if (segmentHtml) segmentHtml.style.width = `${((ordered[i].endTime - ordered[i].startTime) / totalTime) * 100}%`; + }); + + // update the width of the expanding segment using the remaining time + const segExapandHtml = document.getElementById('segment-expanding'); + if (segExapandHtml) + segExapandHtml.style.width = ordered.length === 0 ? '100%' : `${(remainingTime / totalTime) * 100}%`; + }, [props.progress]); + + // useEffect for props.videos - update the ordered array when a new video is added + useEffect(() => { + // this useEffect fired when the videos are being rearragned to the order + // in this case, do nothing. + if (props.orderVideos) return; + + const order = props.videos.length - 1; + // in this case, a new video is added -> push it onto ordered + if (order >= ordered.length) { + const { endTime, startTime } = props.videos.lastElement(); + setOrdered(prevOrdered => { + return [...prevOrdered, { endTime, startTime, order }]; + }); + } + + // in this case, a video is removed + else if (order < ordered.length) { + console.warn('warning: video removed from parent'); + } + }, [props.videos]); + + // useEffect for props.orderVideos - matched the order array with the videos array before the export + useEffect(() => props.setVideos(vids => ordered.map((seg) => vids[seg.order])), [props.orderVideos]); + + // useEffect for removed - handles logic for removing a segment + useEffect(() => { + if (removed === -1) return; + // update total removed time + setTotalRemovedTime(prevRemoved => prevRemoved + (ordered[removed].endTime - ordered[removed].startTime)); + + // put the element on the undo stack + setUndoStack(prevUndo => [...prevUndo, ordered[removed]]); + // remove the segment from the array + setOrdered(prevOrdered => prevOrdered.filter((seg, i) => i !== removed)); + // reset to default/nullish state + setRemoved(-1); + }, [removed]); + + // returns the new currentHover based on the new index + const updateCurrentHover = (segId: number): CurrentHover | null => { + // get the segId of the segment that will become the new bounding area + const rect = progressBarRef.current?.children[segId].getBoundingClientRect() + if (rect == null) return null + return { + index: segId, + minX: rect.x, + maxX: rect.x + rect.width, + } + } + + // pointerdown event for the progress bar + const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { + // don't move the videobox element + e.stopPropagation() + + // if recording, do nothing + if (props.recording) return; + + // get the segment the user clicked on to be dragged + const clickedSegment = e.target as HTMLDivElement & EventTarget + + // get the profess bar ro add event listeners + // don't do anything if null + const progressBar = progressBarRef.current + if (progressBar == null || clickedSegment.id === progressBar.id) return + + // if holding shift key, let's remove that segment + if (e.shiftKey) { + const segId = parseInt(clickedSegment.id.split('-')[1]); + setRemoved(segId); + return + } + + // if holding ctrl key and click, let's undo that segment #hiddenfeature lol + if (e.ctrlKey) { + handleUndo(); + return; + } + + // if we're here, the user is dragging a segment around + // let the progress bar capture all the pointer events until the user releases (pointerUp) + const ptrId = e.pointerId; + progressBar.setPointerCapture(ptrId) + + const rect = clickedSegment.getBoundingClientRect() + // id for segment is like 'segment-1' or 'segment-10', + // so this works to get the id + const segId = parseInt(clickedSegment.id.split('-')[1]) + // set the selected segment to be the one dragged + setDragged(segId) + + // this is the logic for storing the lower X bound and upper X bound to know + // whether a swap is needed between two segments + let currentHover: CurrentHover = { + index: segId, + minX: rect.x, + maxX: rect.x + rect.width, + } + + // create the floating segment that tracks the cursor + const detchedSegment = document.createElement("div") + initDeatchSegment(detchedSegment, rect); + + const updateSegmentOrder = (event: PointerEvent): void => { + event.stopPropagation(); + event.preventDefault(); + + // this fixes a bug where pointerup doesn't fire while cursor is upped while being dragged + if (!progressBar.hasPointerCapture(ptrId)) { + placeSegmentandCleanup(); + return; + } + + followCursor(event, detchedSegment); + + const curX = event.clientX; + // handle the left bound + if (curX < currentHover.minX && currentHover.index > 0) { + swapSegments(currentHover.index, currentHover.index - 1) + currentHover = updateCurrentHover(currentHover.index - 1) ?? currentHover + } + // handle the right bound + else if (curX > currentHover.maxX && currentHover.index < segments.length - 1) { + swapSegments(currentHover.index, currentHover.index + 1) + currentHover = updateCurrentHover(currentHover.index + 1) ?? currentHover + } + } + + // handles when the user is done dragging the segment (pointerUp) + const placeSegmentandCleanup = (event?: PointerEvent): void => { + event?.stopPropagation(); + event?.preventDefault(); + // if they put the segment outside of the bounds, remove it + if (event && (event.clientX < 0 || event.clientX > document.body.clientWidth || event.clientY < 0 || event.clientY > document.body.clientHeight)) + setRemoved(currentHover.index); + + // remove the update event listener for pointermove + progressBar.removeEventListener('pointermove', updateSegmentOrder); + // remove the floating segment from the DOM + detchedSegment.remove(); + // dragged is -1 is equiv to nothing being dragged, so the normal state + // so this will place the segment in it's location and update the segment bar + setDragged(-1); + } + + // event listeners that allow the user to drag and release the floating segment + progressBar.addEventListener('pointermove', updateSegmentOrder); + progressBar.addEventListener('pointerup', placeSegmentandCleanup, { once: true }); + } + + const swapSegments = (oldIndex: number, newIndex: number) => { + if (newIndex == null) return; + setOrdered(prevOrdered => { + const temp = { ...prevOrdered[oldIndex] } + prevOrdered[oldIndex] = prevOrdered[newIndex] + prevOrdered[newIndex] = temp + return prevOrdered + }); + // update visually where the segment is hovering over + setDragged(newIndex); + } + + // functions for the floating segment that tracks the cursor while grabbing it + const initDeatchSegment = (dot: HTMLDivElement, rect: DOMRect) => { + dot.classList.add("segment-selected"); + dot.style.transitionDuration = '0s'; + dot.style.position = 'absolute'; + dot.style.zIndex = '999'; + dot.style.width = `${rect.width}px`; + dot.style.height = `${rect.height}px`; + dot.style.left = `${rect.x}px`; + dot.style.top = `${rect.y}px`; + dot.draggable = false; + document.body.append(dot); + } + const followCursor = (event: PointerEvent, dot: HTMLDivElement): void => { + // event.stopPropagation() + const { width, height } = dot.getBoundingClientRect(); + dot.style.left = `${event.clientX - width / 2}px`; + dot.style.top = `${event.clientY - height / 2}px`; + } + - // const handleClick = (e: React.MouseEvent) => { - // let progressbar = document.getElementById('progressbar')! - // let bounds = progressbar!.getBoundingClientRect(); - // let x = e.clientX - bounds.left; - // let percent = x / progressbar.clientWidth * 100 - - // for (let i = 0; i < props.marks.length; i++) { - // let start = i == 0 ? 0 : props.marks[i-1]; - // if (percent > start && percent < props.marks[i]) { - // props.playSegment(i) - // // console.log(i) - // // console.log(percent) - // // console.log(props.marks[i]) - // break - // } - // } - // } - - return ( - <div className="progressbar" id="progressbar"> - <div - className="progressbar done" - style={{ width: `${props.progress}%` }} - // onClick={handleClick} - ></div> - {props.marks.map((mark) => { - return <div - className="progressbar mark" - style={{ width: `${mark}%` }} - ></div> - })} - </div> - ) + return ( + <div className="progressbar" id="progressbar" onPointerDown={onPointerDown} ref={progressBarRef}> + {segments} + </div> + ) }
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/RecordingBox.tsx b/src/client/views/nodes/RecordingBox/RecordingBox.tsx index eac1c63f9..5e97e3eb5 100644 --- a/src/client/views/nodes/RecordingBox/RecordingBox.tsx +++ b/src/client/views/nodes/RecordingBox/RecordingBox.tsx @@ -16,46 +16,45 @@ import { Id } from "../../../../fields/FieldSymbols"; @observer export class RecordingBox extends ViewBoxBaseComponent() { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(RecordingBox, fieldKey); } - - private _ref: React.RefObject<HTMLDivElement> = React.createRef(); - - constructor(props: any) { - super(props); - } - - componentDidMount() { - console.log("set native width and height") - Doc.SetNativeWidth(this.dataDoc, 1280); - Doc.SetNativeHeight(this.dataDoc, 720); - } - - @observable result: Upload.FileInformation | undefined = undefined - @observable videoDuration: number | undefined = undefined - - @action - setVideoDuration = (duration: number) => { - this.videoDuration = duration - } - - @action - setResult = (info: Upload.FileInformation, trackScreen: boolean) => { - this.result = info - this.dataDoc.type = DocumentType.VID; - this.dataDoc[this.fieldKey + "-duration"] = this.videoDuration; - - this.dataDoc.layout = VideoBox.LayoutString(this.fieldKey); - this.dataDoc[this.props.fieldKey] = new VideoField(this.result.accessPaths.agnostic.client); - this.dataDoc[this.fieldKey + "-recorded"] = true; - // stringify the presenation and store it - if (trackScreen) { - this.dataDoc[this.fieldKey + "-presentation"] = JSON.stringify(RecordingApi.Instance.clear()); - } - } - - render() { - return <div className="recordingBox" ref={this._ref}> - {!this.result && <RecordingView setResult={this.setResult} setDuration={this.setVideoDuration} id={this.rootDoc.proto?.[Id]} />} - </div>; - } + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(RecordingBox, fieldKey); } + + private _ref: React.RefObject<HTMLDivElement> = React.createRef(); + + constructor(props: any) { + super(props); + } + + componentDidMount() { + Doc.SetNativeWidth(this.dataDoc, 1280); + Doc.SetNativeHeight(this.dataDoc, 720); + } + + @observable result: Upload.AccessPathInfo | undefined = undefined + @observable videoDuration: number | undefined = undefined + + @action + setVideoDuration = (duration: number) => { + this.videoDuration = duration + } + + @action + setResult = (info: Upload.AccessPathInfo, trackScreen: boolean) => { + this.result = info + this.dataDoc.type = DocumentType.VID; + this.dataDoc[this.fieldKey + "-duration"] = this.videoDuration; + + this.dataDoc.layout = VideoBox.LayoutString(this.fieldKey); + this.dataDoc[this.props.fieldKey] = new VideoField(this.result.accessPaths.client); + this.dataDoc[this.fieldKey + "-recorded"] = true; + // stringify the presenation and store it + if (trackScreen) { + this.dataDoc[this.fieldKey + "-presentation"] = JSON.stringify(RecordingApi.Instance.clear()); + } + } + + render() { + return <div className="recordingBox" ref={this._ref}> + {!this.result && <RecordingView setResult={this.setResult} setDuration={this.setVideoDuration} id={this.rootDoc.proto?.[Id] || ''} />} + </div>; + } } diff --git a/src/client/views/nodes/RecordingBox/RecordingView.scss b/src/client/views/nodes/RecordingBox/RecordingView.scss index 9b2f6d070..2e6f6bc26 100644 --- a/src/client/views/nodes/RecordingBox/RecordingView.scss +++ b/src/client/views/nodes/RecordingBox/RecordingView.scss @@ -18,13 +18,13 @@ button { width: 100%; // display: flex; pointer-events: all; - background-color: grey; + background-color: black; } .video-wrapper { // max-width: 600px; // max-width: 700px; - position: relative; + // position: relative; display: flex; justify-content: center; // overflow: hidden; @@ -33,7 +33,7 @@ button { } .video-wrapper:hover .controls { - bottom: 30px; + bottom: 34.5px; transform: translateY(0%); opacity: 100%; } @@ -43,8 +43,8 @@ button { align-items: center; justify-content: space-evenly; position: absolute; - padding: 14px; - width: 100%; + // padding: 14px; + //width: 100%; max-width: 500px; // max-height: 20%; flex-wrap: wrap; @@ -56,7 +56,14 @@ button { // transform: translateY(150%); transition: all 0.3s ease-in-out; // opacity: 0%; - bottom: 30px; + bottom: 34.5px; + height: 60px; + right: 2px; + // bottom: -150px; +} + +.controls:active { + bottom: 40px; // bottom: -150px; } @@ -127,9 +134,8 @@ button { .controls-inner-container { display: flex; flex-direction: row; - justify-content: center; - width: 100%; - + align-content: center; + position: relative; } .record-button-wrapper { @@ -180,14 +186,14 @@ button { height: 100%; display: flex; flex-direction: row; - align-items: center; - position: absolute; + align-content: center; + position: relative; top: 0; bottom: 0; &.video-edit-wrapper { - right: 50% - 15; + // right: 50% - 15; .track-screen { font-weight: 200; @@ -197,10 +203,11 @@ button { &.track-screen-wrapper { - right: 50% - 30; + // right: 50% - 30; .track-screen { font-weight: 200; + color: aqua; } } diff --git a/src/client/views/nodes/RecordingBox/RecordingView.tsx b/src/client/views/nodes/RecordingBox/RecordingView.tsx index b95335792..e8737dce2 100644 --- a/src/client/views/nodes/RecordingBox/RecordingView.tsx +++ b/src/client/views/nodes/RecordingBox/RecordingView.tsx @@ -7,17 +7,18 @@ import { FaCheckCircle } from 'react-icons/fa'; import { IconContext } from "react-icons"; import { Networking } from '../../../Network'; import { Upload } from '../../../../server/SharedMediaTypes'; - -import { RecordingApi } from '../../../util/RecordingApi'; import { emptyFunction, returnFalse, returnTrue, setupMoveUpEvents } from '../../../../Utils'; +import { RecordingApi } from '../../../util/RecordingApi'; +import { DashUploadUtils } from '../../../../server/DashUploadUtils'; -interface MediaSegment { +export interface MediaSegment { videoChunks: any[], - endTime: number + endTime: number, + startTime: number } interface IRecordingViewProps { - setResult: (info: Upload.FileInformation, trackScreen: boolean) => void + setResult: (info: Upload.AccessPathInfo, trackScreen: boolean) => void setDuration: (seconds: number) => void id: string } @@ -32,7 +33,13 @@ export function RecordingView(props: IRecordingViewProps) { const [playing, setPlaying] = useState(false); const [progress, setProgress] = useState(0); + // acts as a "refresh state" to tell progressBar when to undo + const [doUndo, setDoUndo] = useState(false); + // whether an undo can occur or not + const [canUndo, setCanUndo] = useState(false); + const [videos, setVideos] = useState<MediaSegment[]>([]); + const [orderVideos, setOrderVideos] = useState<boolean>(false); const videoRecorder = useRef<MediaRecorder | null>(null); const videoElementRef = useRef<HTMLVideoElement | null>(null); @@ -53,31 +60,38 @@ export function RecordingView(props: IRecordingViewProps) { } } - useEffect(() => { + useEffect(() => { if (finished) { - props.setDuration(recordingTimer * 100) - let allVideoChunks: any = [] - videos.forEach((vid) => { - console.log(vid.videoChunks) - allVideoChunks = allVideoChunks.concat(vid.videoChunks) - }) - - const videoFile = new File(allVideoChunks, "video.mkv", { type: allVideoChunks[0].type, lastModified: Date.now() }); - - Networking.UploadFilesToServer(videoFile) - .then((data) => { - const result = data[0].result - if (!(result instanceof Error)) { // convert this screenshotBox into normal videoBox - props.setResult(result, trackScreen) - } else { - alert("video conversion failed"); - } + (async () => { + const inputPaths: string[] = []; + const videoFiles: File[] = [] + videos.forEach(async (vid, i) => { + const videoFile = new File(vid.videoChunks, `segvideo${i}.mkv`, { type: vid.videoChunks[0].type, lastModified: Date.now() }); + videoFiles.push(videoFile); + + const { name } = videoFile; + inputPaths.push(name) }) + // upload the segments to the server and get their server access paths + const serverPaths: string[] = (await Networking.UploadFilesToServer(videoFiles)) + .map(res => (res.result instanceof Error) ? '' : res.result.accessPaths.agnostic.server) + + // concat the segments together using post call + const result: Upload.AccessPathInfo | Error = await Networking.PostToServer('/concatVideos', serverPaths) + if (!(result instanceof Error)) { + props.setResult(result, trackScreen) + } else { + alert("video conversion failed"); + } + })(); } + }, [videos]) - + // this will call upon the progress bar to edit videos to be in the correct order + useEffect(() => { + if (finished) setOrderVideos(true); }, [finished]) useEffect(() => { @@ -85,13 +99,13 @@ export function RecordingView(props: IRecordingViewProps) { if (!navigator.mediaDevices) { console.log('This browser does not support getUserMedia.') } - console.log('This device has the correct media devices.') + // console.log('This device has the correct media devices.') }, []) useEffect(() => { // get access to the video element on every render videoElementRef.current = document.getElementById(`video-${props.id}`) as HTMLVideoElement; - }) + }); useEffect(() => { let interval: any = null; @@ -146,39 +160,40 @@ export function RecordingView(props: IRecordingViewProps) { // if we have a last portion if (videoChunks.length > 1) { // append the current portion to the video pieces - setVideos(videos => [...videos, { videoChunks: videoChunks, endTime: recordingTimerRef.current }]) + setVideos(videos => [...videos, { videoChunks: videoChunks, endTime: recordingTimerRef.current, startTime: videos?.lastElement()?.endTime || 0 }]) } // reset the temporary chunks videoChunks = [] setRecording(false); - setFinished(true); trackScreen && RecordingApi.Instance.pause(); } // recording paused - videoRecorder.current.onpause = (event: any) => { - // append the current portion to the video pieces - setVideos(videos => [...videos, { videoChunks: videoChunks, endTime: recordingTimerRef.current }]) - - // reset the temporary chunks - videoChunks = [] - setRecording(false); - trackScreen && RecordingApi.Instance.pause(); - } - - videoRecorder.current.onresume = async (event: any) => { - await startShowingStream(); - setRecording(true); - trackScreen && RecordingApi.Instance.resume(); - } + // videoRecorder.current.onpause = (event: any) => { + // // append the current portion to the video pieces + // setVideos(videos => [...videos, { videoChunks: videoChunks, endTime: recordingTimerRef.current, startTime: videos?.lastElement()?.endTime || 0 }]) + + // // reset the temporary chunks + // videoChunks = [] + // setRecording(false); + // trackScreen && RecordingApi.Instance.pause(); + // } + + // videoRecorder.current.onresume = async (event: any) => { + // await startShowingStream(); + // setRecording(true); + // trackScreen && RecordingApi.Instance.resume(); + // } videoRecorder.current.start(200) } - const stop = () => { + const stop = (e: React.PointerEvent) => { + e.stopPropagation(); if (videoRecorder.current) { + setFinished(true); if (videoRecorder.current.state !== "inactive") { videoRecorder.current.stop(); // recorder.current.stream.getTracks().forEach((track: any) => track.stop()) @@ -186,10 +201,11 @@ export function RecordingView(props: IRecordingViewProps) { } } - const pause = () => { + const pause = (e: React.PointerEvent) => { + e.stopPropagation() if (videoRecorder.current) { if (videoRecorder.current.state === "recording") { - videoRecorder.current.pause(); + videoRecorder.current.stop(); } } } @@ -200,24 +216,18 @@ export function RecordingView(props: IRecordingViewProps) { if (!videoRecorder.current || videoRecorder.current.state === "inactive") { record(); } else if (videoRecorder.current.state === "paused") { - videoRecorder.current.resume(); + videoRecorder.current.start(); } return true; // cancels propagation to documentView to avoid selecting it. }, false, false); } - const clearPrevious = () => { - const numVideos = videos.length - setRecordingTimer(numVideos == 1 ? 0 : videos[numVideos - 2].endTime) - setVideoProgressHelper(numVideos == 1 ? 0 : videos[numVideos - 2].endTime) - setVideos(videos.filter((_, idx) => idx !== numVideos - 1)); + const undoPrevious = (e: React.PointerEvent) => { + e.stopPropagation(); + setDoUndo(prev => !prev); } - const handleOnTimeUpdate = () => { - if (playing) { - setVideoProgressHelper(videoElementRef.current!.currentTime) - } - }; + const handleOnTimeUpdate = () => playing && setVideoProgressHelper(videoElementRef.current!.currentTime); const millisecondToMinuteSecond = (milliseconds: number) => { const toTwoDigit = (digit: number) => { @@ -253,11 +263,11 @@ export function RecordingView(props: IRecordingViewProps) { {!recording && (videos.length > 0 ? <div className="options-wrapper video-edit-wrapper"> - {/* <IconContext.Provider value={{ color: "grey", className: "video-edit-buttons" }}> - <MdBackspace onClick={clearPrevious} /> - </IconContext.Provider> */} + <IconContext.Provider value={{ color: "grey", className: "video-edit-buttons", style: {display: canUndo ? 'inherit' : 'none'} }}> + <MdBackspace onPointerDown={undoPrevious} /> + </IconContext.Provider> <IconContext.Provider value={{ color: "#cc1c08", className: "video-edit-buttons" }}> - <FaCheckCircle onClick={stop} /> + <FaCheckCircle onPointerDown={stop} /> </IconContext.Provider> </div> @@ -271,12 +281,17 @@ export function RecordingView(props: IRecordingViewProps) { </div> - <ProgressBar - progress={progress} - marks={videos.map((elt) => elt.endTime / MAXTIME * 100)} - // playSegment={playSegment} - /> </div> + + <ProgressBar + videos={videos} + setVideos={setVideos} + orderVideos={orderVideos} + progress={progress} + recording={recording} + doUndo={doUndo} + setCanUndo={setCanUndo} + /> </div> </div>) }
\ No newline at end of file diff --git a/src/client/views/nodes/trails/PresElementBox.tsx b/src/client/views/nodes/trails/PresElementBox.tsx index a2d76978a..fba7e7af7 100644 --- a/src/client/views/nodes/trails/PresElementBox.tsx +++ b/src/client/views/nodes/trails/PresElementBox.tsx @@ -362,8 +362,8 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { } else { // if we dont have any recording const recording = Docs.Create.WebCamDocument("", { - _width: 400, _height: 200, - // hideDocumentButtonBar: true, + _width: 384, _height: 216, + hideDocumentButtonBar: true, hideDecorationTitle: true, hideOpenButton: true, // hideDeleteButton: true, diff --git a/src/server/ApiManagers/UploadManager.ts b/src/server/ApiManagers/UploadManager.ts index e7b7056a1..217c88107 100644 --- a/src/server/ApiManagers/UploadManager.ts +++ b/src/server/ApiManagers/UploadManager.ts @@ -40,7 +40,16 @@ export function clientPathToFile(directory: Directory, filename: string) { export default class UploadManager extends ApiManager { - protected initialize(register: Registration): void { + protected initialize(register: Registration): void { + + register({ + method: Method.POST, + subscription: "/concatVideos", + secureHandler: async ({ req, res }) => { + // req.body contains the array of server paths to the videos + _success(res, await DashUploadUtils.concatVideos(req.body)); + } + }); register({ method: Method.POST, @@ -50,7 +59,7 @@ export default class UploadManager extends ApiManager { form.keepExtensions = true; form.uploadDir = pathToDirectory(Directory.parsed_files); return new Promise<void>(resolve => { - form.parse(req, async (_err, _fields, files) => { + form.parse(req, async (_err, _fields, files) => { const results: Upload.FileResponse[] = []; for (const key in files) { const f = files[key]; diff --git a/src/server/DashUploadUtils.ts b/src/server/DashUploadUtils.ts index 552ab57a5..be30c115d 100644 --- a/src/server/DashUploadUtils.ts +++ b/src/server/DashUploadUtils.ts @@ -1,9 +1,9 @@ import { green, red } from 'colors'; import { ExifImage } from 'exif'; +import * as exifr from 'exifr'; import { File } from 'formidable'; import { createWriteStream, existsSync, readFileSync, rename, unlinkSync, writeFile } from 'fs'; import * as path from 'path'; -import * as exifr from 'exifr'; import { basename } from "path"; import * as sharp from 'sharp'; import { Stream } from 'stream'; @@ -17,9 +17,11 @@ import { resolvedServerUrl } from "./server_Initialization"; import { AcceptableMedia, Upload } from './SharedMediaTypes'; import request = require('request-promise'); import formidable = require('formidable'); +import { file } from 'jszip'; const { exec } = require("child_process"); const parse = require('pdf-parse'); const ffmpeg = require("fluent-ffmpeg"); +const fs = require("fs"); const requestImageSize = require("../client/util/request-image-size"); export enum SizeSuffix { @@ -60,6 +62,45 @@ export namespace DashUploadUtils { const type = "content-type"; const { imageFormats, videoFormats, applicationFormats, audioFormats } = AcceptableMedia; //TODO:glr + + export async function concatVideos(filePaths: string[]): Promise<Upload.AccessPathInfo> { + // make a list of paths to create the ordered text file for ffmpeg + const inputListName = 'concat.txt'; + const textFilePath = path.join(filesDirectory, inputListName); + // make a list of paths to create the ordered text file for ffmpeg + const filePathsText = filePaths.map(filePath => `file '${filePath}'`).join('\n'); + // write the text file to the file system + writeFile(textFilePath, filePathsText, (err) => console.log(err)); + console.log('fileTextPaths', filePathsText) + + // make output file name based on timestamp + const outputFileName = `output-${Utils.GenerateGuid()}.mp4`; + // create the output file path in the videos directory + const outputFilePath = path.join(pathToDirectory(Directory.videos), outputFileName); + + // concatenate the videos + await new Promise((resolve, reject) => { + console.log('concatenating videos'); + var merge = ffmpeg(); + merge.input(textFilePath) + .inputOptions(['-f concat', '-safe 0']) + .outputOptions('-c copy') + //.videoCodec("copy") + .save(outputFilePath) + .on("error", reject) + .on("end", resolve); + }) + + // delete concat.txt from the file system + unlinkSync(textFilePath); + // delete the old segment videos from the server + filePaths.forEach(filePath => unlinkSync(filePath)); + + // return the path(s) to the output file + return { + accessPaths: getAccessPaths(Directory.videos, outputFileName) + } + } export function uploadYoutube(videoId: string): Promise<Upload.FileResponse> { console.log("UPLOAD " + videoId); @@ -94,6 +135,7 @@ export namespace DashUploadUtils { } case "video": if (format.includes("x-matroska")) { + console.log("case video"); await new Promise(res => ffmpeg(file.path) .videoCodec("copy") // this will copy the data instead of reencode it .save(file.path.replace(".mkv", ".mp4")) @@ -220,6 +262,7 @@ export namespace DashUploadUtils { } let resolvedUrl: string; /** + * * At this point, we want to take whatever url we have and make sure it's requestable. * Anything that's hosted by some other website already is, but if the url is a local file url * (locates the file on this server machine), we have to resolve the client side url by cutting out the |