diff --git a/backend/requirements.txt b/backend/requirements.txt index c702760..37a5d7c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,4 +8,5 @@ passlib requests python-multipart Pillow -joblib \ No newline at end of file +joblib +celery \ No newline at end of file diff --git a/backend/src/models/sign.py b/backend/src/models/sign.py index 700f4f3..172c250 100644 --- a/backend/src/models/sign.py +++ b/backend/src/models/sign.py @@ -1,7 +1,9 @@ from typing import List +import numpy as np import requests from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel from src.exceptions.base_exception import BaseException @@ -51,6 +53,26 @@ class Sign(SQLModelExtended, table=True): if self.video_url is None: raise BaseException(404, "Video url not found") + @classmethod + async def get_random(self, session: AsyncSession): + signs = await self.get_all(select_in_load=[Sign.sign_videos],session=session) + + sign_videos = [len(s.sign_videos) for s in signs] + + # get probability based on number of videos, lower must be more likely + # the sum must be 1 + + random_prob = [1 / (x + 1) for x in sign_videos] + random_prob = random_prob / np.sum(random_prob) + + # get random sign + sign = np.random.choice(signs, p=random_prob) + + return sign + + + + class SignOut(BaseModel): id: int url: str @@ -59,3 +81,10 @@ class SignOut(BaseModel): video_url: str sign_videos: List[SignVideo] = [] + +class SignOutSimple(BaseModel): + id: int + url: str + name: str + sign_id: str + video_url: str \ No newline at end of file diff --git a/backend/src/routers/signs.py b/backend/src/routers/signs.py index 3b5aaaf..50daaf5 100644 --- a/backend/src/routers/signs.py +++ b/backend/src/routers/signs.py @@ -13,7 +13,7 @@ import src.settings as settings from src.database.database import get_session from src.exceptions.login_exception import LoginException from src.models.auth import Login, User -from src.models.sign import Sign, SignOut +from src.models.sign import Sign, SignOut, SignOutSimple from src.models.signvideo import SignVideo from src.models.token import TokenExtended @@ -22,6 +22,12 @@ router = APIRouter(prefix="/signs") class SignUrl(BaseModel): url: str +@router.get("/random", status_code=status.HTTP_200_OK, response_model=SignOutSimple) +async def get_random_sign(session: AsyncSession = Depends(get_session)): + # get a random sign where there is not much data from yet + sign = await Sign.get_random(session=session) + return sign + @router.post("/", status_code=status.HTTP_201_CREATED, response_model=SignOut) async def add_sign(url: SignUrl, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)): Authorize.jwt_required() @@ -127,6 +133,7 @@ async def download_all(background_tasks: BackgroundTasks, Authorize: AuthJWT = D return response + # define a function to delete the zip file async def delete_zip_file(zip_path): os.remove(zip_path) \ No newline at end of file diff --git a/backend/src/routers/signvideo.py b/backend/src/routers/signvideo.py index 7784cf4..b975084 100644 --- a/backend/src/routers/signvideo.py +++ b/backend/src/routers/signvideo.py @@ -7,9 +7,11 @@ import random import string import subprocess +from celery import Celery from fastapi import (APIRouter, BackgroundTasks, Depends, File, UploadFile, status) -from fastapi.responses import FileResponse +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import FileResponse, JSONResponse from fastapi_jwt_auth import AuthJWT from joblib import Memory from pydantic import BaseModel @@ -26,6 +28,9 @@ from src.utils.cryptography import verify_password # Create a Memory object that caches data to the specified directory cache_dir = settings.CACHE_PATH + +celery_app = Celery('tasks', broker=settings.CELERY_REDIS) + async def extract_thumbnail(video_path): proc = await asyncio.create_subprocess_exec( "ffmpeg", "-i", video_path, "-ss", "00:00:02.000", "-vframes", "1", "-f", "image2pipe", "-" @@ -36,15 +41,18 @@ async def extract_thumbnail(video_path): router = APIRouter(prefix="/signs/{sign_id}/video") +@celery_app.task def convert_video(video_filename): command = ['ffmpeg', '-y', '-i', settings.DATA_PATH + "/" + video_filename + ".test", '-c:v', 'libx264', '-c:a', 'aac', '-strict', '-2', '-b:a', '192k', '-c:a', 'aac', '-strict', '-2', '-b:a', '192k', settings.DATA_PATH + "/" + video_filename] + + # run the command subprocess.run(command) # delete the temporary file os.remove(settings.DATA_PATH + "/" + video_filename + ".test") - # create the thumbnail - extract_thumbnail(settings.DATA_PATH + "/" + video_filename) + # create thumbnail + asyncio.run(extract_thumbnail(settings.DATA_PATH + "/" + video_filename)) # endpoint to upload a file and save it in the data folder @@ -52,15 +60,14 @@ def convert_video(video_filename): async def sign_video( sign_id: int, video: UploadFile, - background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), - Authorize: AuthJWT = Depends(), + # Authorize: AuthJWT = Depends(), ): - Authorize.jwt_required() - current_user_id: int = Authorize.get_jwt_subject() - user = await User.get_by_id(current_user_id, session) - if not user: - raise LoginException("User not found") + # Authorize.jwt_required() + # current_user_id: int = Authorize.get_jwt_subject() + # user = await User.get_by_id(current_user_id, session) + # if not user: + # raise LoginException("User not found") sign = await Sign.get_by_id(sign_id, session) if not sign: raise LoginException("Sign not found") @@ -79,12 +86,13 @@ async def sign_video( with open(settings.DATA_PATH + "/" + video.filename + ".test", 'wb') as f: f.write(video.file.read()) - # convert the video in the background - background_tasks.add_task(convert_video, video.filename) + convert_video.delay(video.filename) await sign_video.save(session) - return sign_video + response = JSONResponse(content={"sign_video": sign_video.dict()}) + response.headers["Connection"] = "close" + return response @router.get("/{video_id}/thumbnail/", status_code=status.HTTP_200_OK) async def sign_video_thumbnail( diff --git a/backend/src/settings.py b/backend/src/settings.py index 7f2ee07..e933acf 100644 --- a/backend/src/settings.py +++ b/backend/src/settings.py @@ -17,6 +17,8 @@ DB_PORT: str = os.getenv("DB_PORT", "5432") DB_USE_SQLITE: bool = os.getenv("DB_USE_SQLITE", False) DB_SQLITE_PATH: str = os.getenv("DB_SQLITE_PATH", "./sqlite.db") +CELERY_REDIS: str = os.getenv("CELERY_REDIS", "redis://localhost:6379/0") + """Storage""" DATA_PATH: str = os.getenv("DATA_PATH", "data") CACHE_PATH: str = os.getenv("CACHE_PATH", "cache") @@ -31,4 +33,4 @@ REFRESH_EXPIRES: int = int(os.getenv("REFRESH_EXPIRES", 864000)) """Standard User""" DEFAULT_USER_ENABLED: bool = os.getenv("DEFAULT_USER_ENABLED", False) DEFAULT_USER_EMAIL: str = os.getenv("DEFAULT_USER_EMAIL", "test@test.com") -DEFAULT_USER_PASSWORD: str = os.getenv("DEFAULT_USER_PASSWORD", "test") +DEFAULT_USER_PASSWORD: str = os.getenv("DEFAULT_USER_PASSWORD", "test") \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c320400..9a43f7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import Login from './components/LoginPage'; import ProtectedRoute from './components/ProtectedRoute'; +import RandomSignUpload from './components/RandomVideoUpload'; import SignDetailpage from './components/SignDetailPage'; import SignsPage from './components/SignsPage'; @@ -9,9 +10,10 @@ function App() { return ( - } /> - } /> - } /> + } /> + } /> + } /> + } /> ); diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 74a978a..c5fd973 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -10,14 +10,14 @@ const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => { if (expired_at < new Date()) { localStorage.removeItem('accessToken'); localStorage.removeItem('expirationDate'); - return ; + return ; } } if (!isLoggedIn) { - return ; + return ; } return ( diff --git a/frontend/src/components/RandomVideoUpload.tsx b/frontend/src/components/RandomVideoUpload.tsx new file mode 100755 index 0000000..ea01c34 --- /dev/null +++ b/frontend/src/components/RandomVideoUpload.tsx @@ -0,0 +1,175 @@ +import React, { useState, useRef, useEffect, ChangeEvent } from 'react'; +import { Sign, SignVideo, SimpleSign } from '../types/sign'; +import { useParams } from 'react-router-dom'; +import { getRandomSign, getSign } from '../services/signs'; +import ReactModal from 'react-modal'; +import { acceptVideo, deleteVideo, uploadSignVideo } from '../services/signvideos'; +import { LoadingButton } from './loading-button/loading-button'; +import VideoRecorder from 'react-video-recorder'; +import SignVideoGrid from './SignVideoGrid'; +import SignVideoPlayer from './SignVideoPlayer'; + + +const RandomSignUpload: React.FC = () => { + + const [sign, setSign] = useState(null); + const [signVideo, setSignVideo] = useState(null); + + const [recordedBlob, setRecordedBlob] = useState(null); + const signVideoRef = useRef(null); + + const popupVideoRef = useRef(null); + const [popUpShown, setPopUpShown] = useState(false); + const [recorderKey, setRecorderKey] = useState(1); + const [uploadProgress, setUploadProgress] = useState(null); + const [videoUrl, setVideoUrl] = useState(null); + + useEffect(() => { + if (recordedBlob) { + setVideoUrl(URL.createObjectURL(recordedBlob)); + setPopUpShown(true); + } else { + setVideoUrl(null); + } + }, [recordedBlob]); + + const handleUploadProgress = (progess: number) => { + if (progess) { + setUploadProgress(progess); + } + } + + + const handleUpload = async () => { + setUploadProgress(0); + console.log("Uploading video...") + uploadSignVideo(sign!.id, recordedBlob!, handleUploadProgress).then((response) => { + + console.log("upload complete"); + setPopUpShown(false); + // get new random sign + get_random_sign(); + + // add the new sign video to the sign + const newSign = { ...sign! }; + + setSign(newSign); + setUploadProgress(100); + }).catch((error) => { + setUploadProgress(null); + } + ); + } + + useEffect(() => { + if (signVideoRef.current) { + signVideoRef.current.play(); + } + }, []); + + const get_random_sign = () => { + getRandomSign().then((response) => { + // check if the sign is different from the current one + if (sign === null || sign.id !== response.id) { + setSign(response); + // set the video url + setSignVideo(response.video_url); + } else { + // get a new sign + get_random_sign(); + } + } + ); + } + + + useEffect(() => { + // get random sign + get_random_sign(); + }, []); + + const dismissPopup = () => { + setPopUpShown(false); + // remove the recorded blob + setRecordedBlob(null); + }; + + return ( +
+ + { + sign ?
+
+

{sign.name}

+
+
+ +
+ +
+ + { + signVideo && +
+ +
+ } + + +
+ { + setRecordedBlob(blob) + // reset the VideoRecorder + setRecorderKey(prevKey => prevKey + 1); + }} + showReplayControls={false} + replayVideoAutoplayAndLoopOff={true} + isOnInitially + timeLimit={4000} + /> +
+
+
:
Loading...
+ } + + {videoUrl && +
+
+ } + {(uploadProgress === null || uploadProgress === 0) && + + } +
+
+ ); +}; + + +export default RandomSignUpload; diff --git a/frontend/src/services/signs.ts b/frontend/src/services/signs.ts index 7685694..ca4ce1c 100644 --- a/frontend/src/services/signs.ts +++ b/frontend/src/services/signs.ts @@ -88,4 +88,8 @@ const deleteSign = async (id: number) => { return response.json(); }; -export { addSign, getSigns, getSign, downloadSigns, deleteSign }; \ No newline at end of file +const getRandomSign = async () => { + const response = await fetch(`${process.env.REACT_APP_API_URL}/signs/random`); + return response.json(); +} +export { addSign, getSigns, getSign, downloadSigns, deleteSign, getRandomSign }; \ No newline at end of file diff --git a/frontend/src/services/signvideos.ts b/frontend/src/services/signvideos.ts index 39f6416..be046d4 100644 --- a/frontend/src/services/signvideos.ts +++ b/frontend/src/services/signvideos.ts @@ -1,17 +1,17 @@ import axios from 'axios'; const uploadSignVideo = async (id: number, recordedBlob: Blob, onUploadProgress: ((arg0: number) => void)) => { - // get access token from local storage - const token = localStorage.getItem('accessToken'); let formData = new FormData(); formData.append('video', recordedBlob); // make request to get signs + console.log("Making request") const response = await axios.post(`${process.env.REACT_APP_API_URL}/signs/${id}/video/`, formData, { headers: { - Authorization: `Bearer ${token}`, ContentType: 'multipart/form-data', + // close the connection after the request is sent + Connection: 'close', }, onUploadProgress: (progressEvent) => { onUploadProgress( diff --git a/frontend/src/types/sign.ts b/frontend/src/types/sign.ts index b4bdcf5..7894b3a 100644 --- a/frontend/src/types/sign.ts +++ b/frontend/src/types/sign.ts @@ -10,4 +10,12 @@ export interface Sign { sign_id: string; video_url: string; sign_videos: [SignVideo]; -} \ No newline at end of file +} + +export interface SimpleSign { + id: number; + url: string; + name: string; + sign_id: string; + video_url: string; +}