From e955a16abf53ff7ea9c46f32e2c378397e9db166 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sat, 9 Nov 2024 09:30:54 +0000 Subject: [PATCH] Thought I had staged it --- app/api/reading.py | 5 +- app/api/speaking.py | 119 +++++------ app/configs/constants.py | 81 -------- app/configs/dependency_injection.py | 16 +- app/controllers/abc/speaking.py | 16 +- app/controllers/impl/speaking.py | 45 ++-- app/dtos/speaking.py | 21 +- app/dtos/video.py | 15 ++ app/services/abc/exam/speaking.py | 15 +- app/services/abc/third_parties/vid_gen.py | 14 ++ app/services/impl/exam/speaking.py | 194 +----------------- .../impl/third_parties/elai/__init__.py | 87 ++++---- .../impl/third_parties/elai/avatars.json | 58 ++++++ .../impl/third_parties/heygen/__init__.py | 93 +++++++++ .../impl/third_parties/heygen/avatars.json | 30 +++ 15 files changed, 343 insertions(+), 466 deletions(-) create mode 100644 app/dtos/video.py create mode 100644 app/services/impl/third_parties/elai/avatars.json create mode 100644 app/services/impl/third_parties/heygen/__init__.py create mode 100644 app/services/impl/third_parties/heygen/avatars.json diff --git a/app/api/reading.py b/app/api/reading.py index 9088484..371731d 100644 --- a/app/api/reading.py +++ b/app/api/reading.py @@ -1,8 +1,10 @@ +import random from typing import Optional from dependency_injector.wiring import Provide, inject from fastapi import APIRouter, Depends, Path, Query, UploadFile +from app.configs.constants import EducationalContent from app.dtos.reading import ReadingDTO from app.middlewares import Authorized, IsAuthenticatedViaBearerToken from app.controllers.abc import IReadingController @@ -21,8 +23,6 @@ async def upload( solutions: UploadFile = None, reading_controller: IReadingController = Depends(Provide[controller]) ): - print(exercises.filename) - #print(solutions.filename) return await reading_controller.import_exam(exercises, solutions) @reading_router.get( @@ -36,6 +36,7 @@ async def generate_passage( passage: int = Path(..., ge=1, le=3), reading_controller: IReadingController = Depends(Provide[controller]) ): + topic = random.choice(EducationalContent.TOPICS) if not topic else topic return await reading_controller.generate_reading_passage(passage, topic, word_count) @reading_router.post( diff --git a/app/api/speaking.py b/app/api/speaking.py index f5646be..42da89f 100644 --- a/app/api/speaking.py +++ b/app/api/speaking.py @@ -2,31 +2,57 @@ import random from typing import Optional from dependency_injector.wiring import inject, Provide -from fastapi import APIRouter, Path, Query, Depends, BackgroundTasks +from fastapi import APIRouter, Path, Query, Depends +from pydantic import BaseModel +from app.dtos.video import Task, TaskStatus from app.middlewares import Authorized, IsAuthenticatedViaBearerToken from app.configs.constants import EducationalContent from app.controllers.abc import ISpeakingController -from app.dtos.speaking import ( - SaveSpeakingDTO, GenerateVideo1DTO, GenerateVideo2DTO, GenerateVideo3DTO -) controller = "speaking_controller" speaking_router = APIRouter() -@speaking_router.get( - '/1', +class Video(BaseModel): + text: str + avatar: Optional[str] = None + +@speaking_router.post( + '/media', dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))] ) @inject -async def get_speaking_task( - first_topic: str = Query(default=random.choice(EducationalContent.MTI_TOPICS)), - second_topic: str = Query(default=random.choice(EducationalContent.MTI_TOPICS)), - difficulty: str = Query(default=random.choice(EducationalContent.DIFFICULTIES)), +async def generate_video( + video: Video, speaking_controller: ISpeakingController = Depends(Provide[controller]) ): - return await speaking_controller.get_speaking_part(1, first_topic, difficulty, second_topic) + return await speaking_controller.generate_video(video.text, video.avatar) + + +@speaking_router.get( + '/media/{vid_id}', + dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))] +) +@inject +async def pool_video( + vid_id: str = Path(...), + speaking_controller: ISpeakingController = Depends(Provide[controller]) +): + return await speaking_controller.pool_video(vid_id) + + + +@speaking_router.get( + '/avatars', + dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))] +) +@inject +async def get_avatars( + speaking_controller: ISpeakingController = Depends(Provide[controller]) +): + return await speaking_controller.get_avatars() + @speaking_router.get( @@ -35,64 +61,27 @@ async def get_speaking_task( ) @inject async def get_speaking_task( - task: int = Path(..., ge=2, le=3), - topic: str = Query(default=random.choice(EducationalContent.MTI_TOPICS)), + task: int = Path(..., ge=1, le=3), + topic: Optional[str] = Query(None), + first_topic: Optional[str] = Query(None), + second_topic: Optional[str] = Query(None), difficulty: str = Query(default=random.choice(EducationalContent.DIFFICULTIES)), speaking_controller: ISpeakingController = Depends(Provide[controller]) ): - return await speaking_controller.get_speaking_part(task, topic, difficulty) + if not second_topic: + topic_or_first_topic = topic if topic else random.choice(EducationalContent.MTI_TOPICS) + else: + topic_or_first_topic = first_topic if first_topic else random.choice(EducationalContent.MTI_TOPICS) + + second_topic = second_topic if second_topic else random.choice(EducationalContent.MTI_TOPICS) + return await speaking_controller.get_speaking_part(task, topic_or_first_topic, second_topic, difficulty) -@speaking_router.post( - '/', - dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))] -) -@inject -async def save_speaking( - data: SaveSpeakingDTO, - background_tasks: BackgroundTasks, - speaking_controller: ISpeakingController = Depends(Provide[controller]) -): - return await speaking_controller.save_speaking(data, background_tasks) +""" + async def generate_video(self, text: str, avatar: str): + return await self._vid_gen.create_video(text, avatar) -@speaking_router.post( - '/generate_video/1', - dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))] -) -@inject -async def generate_video_1( - data: GenerateVideo1DTO, - speaking_controller: ISpeakingController = Depends(Provide[controller]) -): - return await speaking_controller.generate_video( - 1, data.avatar, data.first_topic, data.questions, second_topic=data.second_topic - ) - - -@speaking_router.post( - '/generate_video/2', - dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))] -) -@inject -async def generate_video_2( - data: GenerateVideo2DTO, - speaking_controller: ISpeakingController = Depends(Provide[controller]) -): - return await speaking_controller.generate_video( - 2, data.avatar, data.topic, [data.question], prompts=data.prompts, suffix=data.suffix - ) - - -@speaking_router.post( - '/generate_video/3', - dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))] -) -@inject -async def generate_video_3( - data: GenerateVideo3DTO, - speaking_controller: ISpeakingController = Depends(Provide[controller]) -): - return await speaking_controller.generate_video( - 3, data.avatar, data.topic, data.questions - ) + async def pool_video(self, vid_id: str): + return await self._vid_gen.pool_status(vid_id) +""" \ No newline at end of file diff --git a/app/configs/constants.py b/app/configs/constants.py index 9ad8e93..7a7e4ea 100644 --- a/app/configs/constants.py +++ b/app/configs/constants.py @@ -99,87 +99,6 @@ class QuestionType(Enum): READING_PASSAGE_3 = "Reading Passage 3" -class HeygenAvatars(Enum): - MATTHEW_NOAH = "5912afa7c77c47d3883af3d874047aaf" - VERA_CERISE = "9e58d96a383e4568a7f1e49df549e0e4" - EDWARD_TONY = "d2cdd9c0379a4d06ae2afb6e5039bd0c" - TANYA_MOLLY = "045cb5dcd00042b3a1e4f3bc1c12176b" - KAYLA_ABBI = "1ae1e5396cc444bfad332155fdb7a934" - JEROME_RYAN = "0ee6aa7cc1084063a630ae514fccaa31" - TYLER_CHRISTOPHER = "5772cff935844516ad7eeff21f839e43" - -from enum import Enum - -class ELAIAvatars(Enum): - # Works - GIA_BUSINESS = { - "avatar_code": "gia.business", - "avatar_gender": "female", - "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/gia/business/gia_business.png", - "avatar_canvas": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/gia/business/gia_business.png", - "voice_id": "EXAVITQu4vr4xnSDxMaL", - "voice_provider": "elevenlabs" - } - - # Works - VADIM_BUSINESS = { - "avatar_code": "vadim.business", - "avatar_gender": "male", - "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/vadim/business/vadim_business.png", - "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/vadim/business/vadim_business.png", - "voice_id": "flq6f7yk4E4fJM5XTYuZ", - "voice_provider": "elevenlabs" - - } - - ORHAN_BUSINESS = { - "avatar_code": "orhan.business", - "avatar_gender": "male", - "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/orhan/business/orhan.png", - "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/orhan/business/orhan.png", - "voice_id": "en-US-AndrewMultilingualNeural", - "voice_provider": "azure" - - } - - FLORA_BUSINESS = { - "avatar_code": "flora.business", - "avatar_gender": "female", - "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/flora/business/flora_business.png", - "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/flora/business/flora_business.png", - "voice_id": "en-US-JaneNeural", - "voice_provider": "azure" - - } - - SCARLETT_BUSINESS = { - "avatar_code": "scarlett.business", - "avatar_gender": "female", - "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/scarlett/business/scarlett_business.png", - "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/scarlett/business/scarlett_business.png", - "voice_id": "en-US-NancyNeural", - "voice_provider": "azure" - } - - PARKER_CASUAL = { - "avatar_code": "parker.casual", - "avatar_gender": "male", - "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/parker/casual/parker_casual.png", - "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/parker/casual/parker_casual.png", - "voice_id": "en-US-TonyNeural", - "voice_provider": "azure" - } - - ETHAN_BUSINESS = { - "avatar_code": "ethan.business", - "avatar_gender": "male", - "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/ethan/business/ethan_business_low.png", - "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/ethan/business/ethan_business_low.png", - "voice_id": "en-US-JasonNeural", - "voice_provider": "azure" - } - - class FilePaths: AUDIO_FILES_PATH = 'download-audio/' FIREBASE_LISTENING_AUDIO_FILES_PATH = 'listening_recordings/' diff --git a/app/configs/dependency_injection.py b/app/configs/dependency_injection.py index deb9626..10818b1 100644 --- a/app/configs/dependency_injection.py +++ b/app/configs/dependency_injection.py @@ -44,11 +44,17 @@ class DependencyInjector: self._container.llm = providers.Factory(OpenAI, client=self._container.openai_client) self._container.stt = providers.Factory(OpenAIWhisper, model=self._container.whisper_model) self._container.tts = providers.Factory(AWSPolly, client=self._container.polly_client) - with open('app/services/impl/third_parties/elai/elai_conf.json', 'r') as file: + with open('app/services/impl/third_parties/elai/conf.json', 'r') as file: elai_conf = json.load(file) + with open('app/services/impl/third_parties/elai/avatars.json', 'r') as file: + elai_avatars = json.load(file) + + with open('app/services/impl/third_parties/heygen/avatars.json', 'r') as file: + heygen_avatars = json.load(file) + self._container.vid_gen = providers.Factory( - ELAI, client=self._container.http_client, token=os.getenv("ELAI_TOKEN"), conf=elai_conf + Heygen, client=self._container.http_client, token=os.getenv("HEY_GEN_TOKEN"), avatars=heygen_avatars ) self._container.ai_detector = providers.Factory( GPTZero, client=self._container.http_client, gpt_zero_key=os.getenv("GPT_ZERO_API_KEY") @@ -79,8 +85,8 @@ class DependencyInjector: self._container.reading_service = providers.Factory(ReadingService, llm=self._container.llm) self._container.speaking_service = providers.Factory( - SpeakingService, llm=self._container.llm, vid_gen=self._container.vid_gen, - file_storage=self._container.firebase_instance, document_store=self._container.document_store, + SpeakingService, llm=self._container.llm, + file_storage=self._container.firebase_instance, stt=self._container.stt ) @@ -144,7 +150,7 @@ class DependencyInjector: ) self._container.speaking_controller = providers.Factory( - SpeakingController, speaking_service=self._container.speaking_service + SpeakingController, speaking_service=self._container.speaking_service, vid_gen=self._container.vid_gen ) self._container.writing_controller = providers.Factory( diff --git a/app/controllers/abc/speaking.py b/app/controllers/abc/speaking.py index 4a54758..c4ab17d 100644 --- a/app/controllers/abc/speaking.py +++ b/app/controllers/abc/speaking.py @@ -7,19 +7,17 @@ from fastapi import BackgroundTasks class ISpeakingController(ABC): @abstractmethod - async def get_speaking_part(self, task: int, topic: str, difficulty: str, second_topic: Optional[str] = None): + async def get_speaking_part(self, task: int, topic: str, second_topic: str, difficulty: str): pass @abstractmethod - async def save_speaking(self, data, background_tasks: BackgroundTasks): + async def get_avatars(self): pass @abstractmethod - async def generate_video( - self, part: int, avatar: str, topic: str, questions: list[str], - *, - second_topic: Optional[str] = None, - prompts: Optional[list[str]] = None, - suffix: Optional[str] = None, - ): + async def generate_video(self, text: str, avatar: Optional[str]): + pass + + @abstractmethod + async def pool_video(self, vid_id: str): pass diff --git a/app/controllers/impl/speaking.py b/app/controllers/impl/speaking.py index 7190ce2..9837dfe 100644 --- a/app/controllers/impl/speaking.py +++ b/app/controllers/impl/speaking.py @@ -1,47 +1,26 @@ import logging -import uuid +import random from typing import Optional -from fastapi import BackgroundTasks - from app.controllers.abc import ISpeakingController -from app.dtos.speaking import SaveSpeakingDTO - -from app.services.abc import ISpeakingService -from app.configs.constants import ExamVariant, MinTimers -from app.configs.question_templates import getSpeakingTemplate +from app.services.abc import ISpeakingService, IVideoGeneratorService class SpeakingController(ISpeakingController): - def __init__(self, speaking_service: ISpeakingService): + def __init__(self, speaking_service: ISpeakingService, vid_gen: IVideoGeneratorService): self._service = speaking_service + self._vid_gen = vid_gen self._logger = logging.getLogger(__name__) - async def get_speaking_part(self, task: int, topic: str, difficulty: str, second_topic: Optional[str] = None): - return await self._service.get_speaking_part(task, topic, difficulty, second_topic) + async def get_speaking_part(self, task: int, topic: str, second_topic: str, difficulty: str): + return await self._service.get_speaking_part(task, topic, second_topic, difficulty) - async def save_speaking(self, data: SaveSpeakingDTO, background_tasks: BackgroundTasks): - exercises = data.exercises - min_timer = data.minTimer + async def get_avatars(self): + return await self._vid_gen.get_avatars() - template = getSpeakingTemplate() - template["minTimer"] = min_timer + async def generate_video(self, text: str, avatar: Optional[str]): + return await self._vid_gen.create_video(text, avatar) - if min_timer < MinTimers.SPEAKING_MIN_TIMER_DEFAULT: - template["variant"] = ExamVariant.PARTIAL.value - else: - template["variant"] = ExamVariant.FULL.value - - req_id = str(uuid.uuid4()) - self._logger.info(f'Received request to save speaking with id: {req_id}') - - background_tasks.add_task(self._service.create_videos_and_save_to_db, exercises, template, req_id) - - self._logger.info('Started background task to save speaking.') - - # Return response without waiting for create_videos_and_save_to_db to finish - return {**template, "id": req_id} - - async def generate_video(self, *args, **kwargs): - return await self._service.generate_video(*args, **kwargs) + async def pool_video(self, vid_id: str): + return await self._vid_gen.pool_status(vid_id) diff --git a/app/dtos/speaking.py b/app/dtos/speaking.py index 97b9e79..439777f 100644 --- a/app/dtos/speaking.py +++ b/app/dtos/speaking.py @@ -1,9 +1,9 @@ import random -from typing import List, Dict +from typing import List, Dict, Optional from pydantic import BaseModel -from app.configs.constants import MinTimers, ELAIAvatars +from app.configs.constants import MinTimers class SaveSpeakingDTO(BaseModel): @@ -21,22 +21,7 @@ class GradeSpeakingAnswersDTO(BaseModel): class GenerateVideo1DTO(BaseModel): - avatar: str = (random.choice(list(ELAIAvatars))).name + avatar: str = Optional[str] questions: List[str] first_topic: str second_topic: str - - -class GenerateVideo2DTO(BaseModel): - avatar: str = (random.choice(list(ELAIAvatars))).name - prompts: List[str] = [] - suffix: str = "" - question: str - topic: str - - -class GenerateVideo3DTO(BaseModel): - avatar: str = (random.choice(list(ELAIAvatars))).name - questions: List[str] - topic: str - diff --git a/app/dtos/video.py b/app/dtos/video.py new file mode 100644 index 0000000..a827e6b --- /dev/null +++ b/app/dtos/video.py @@ -0,0 +1,15 @@ +import random +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +class TaskStatus(Enum): + STARTED = "STARTED" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + ERROR = "ERROR" + +class Task(BaseModel): + status: TaskStatus + result: Optional[str] = None diff --git a/app/services/abc/exam/speaking.py b/app/services/abc/exam/speaking.py index 07ef32e..4476a54 100644 --- a/app/services/abc/exam/speaking.py +++ b/app/services/abc/exam/speaking.py @@ -6,7 +6,7 @@ class ISpeakingService(ABC): @abstractmethod async def get_speaking_part( - self, part: int, topic: str, difficulty: str, second_topic: Optional[str] = None + self, part: int, topic: str, second_topic: str, difficulty: str ) -> Dict: pass @@ -14,16 +14,3 @@ class ISpeakingService(ABC): async def grade_speaking_task(self, task: int, answers: List[Dict]) -> Dict: pass - @abstractmethod - async def create_videos_and_save_to_db(self, exercises: List[Dict], template: Dict, req_id: str): - pass - - @abstractmethod - async def generate_video( - self, part: int, avatar: str, topic: str, questions: list[str], - *, - second_topic: Optional[str] = None, - prompts: Optional[list[str]] = None, - suffix: Optional[str] = None, - ): - pass diff --git a/app/services/abc/third_parties/vid_gen.py b/app/services/abc/third_parties/vid_gen.py index 5ccaef9..846ec7f 100644 --- a/app/services/abc/third_parties/vid_gen.py +++ b/app/services/abc/third_parties/vid_gen.py @@ -1,8 +1,22 @@ from abc import ABC, abstractmethod +from typing import Dict, List class IVideoGeneratorService(ABC): + def __init__(self, avatars: Dict): + self._avatars = avatars + + async def get_avatars(self) -> List[Dict]: + return [ + {"name": name, "gender": data["avatar_gender"]} + for name, data in self._avatars.items() + ] + @abstractmethod async def create_video(self, text: str, avatar: str): pass + + @abstractmethod + async def pool_status(self, video_id: str): + pass diff --git a/app/services/impl/exam/speaking.py b/app/services/impl/exam/speaking.py index f733537..154bdf5 100644 --- a/app/services/impl/exam/speaking.py +++ b/app/services/impl/exam/speaking.py @@ -9,7 +9,7 @@ from app.repositories.abc import IFileStorage, IDocumentStore from app.services.abc import ISpeakingService, ILLMService, IVideoGeneratorService, ISpeechToTextService from app.configs.constants import ( FieldsAndExercises, GPTModels, TemperatureSettings, - ELAIAvatars, FilePaths + FilePaths ) from app.helpers import TextHelper @@ -17,14 +17,12 @@ from app.helpers import TextHelper class SpeakingService(ISpeakingService): def __init__( - self, llm: ILLMService, vid_gen: IVideoGeneratorService, - file_storage: IFileStorage, document_store: IDocumentStore, + self, llm: ILLMService, + file_storage: IFileStorage, stt: ISpeechToTextService ): self._llm = llm - self._vid_gen = vid_gen self._file_storage = file_storage - self._document_store = document_store self._stt = stt self._logger = logging.getLogger(__name__) @@ -102,7 +100,7 @@ class SpeakingService(ISpeakingService): } async def get_speaking_part( - self, part: int, topic: str, difficulty: str, second_topic: Optional[str] = None + self, part: int, topic: str, second_topic: str, difficulty: str ) -> Dict: task_values = self._tasks[f'task_{part}']['get'] @@ -416,190 +414,6 @@ class SpeakingService(ISpeakingService): ) return response["fixed_text"] - async def create_videos_and_save_to_db(self, exercises, template, req_id): - template = await self._create_video_per_part(exercises, template, 1) - template = await self._create_video_per_part(exercises, template, 2) - template = await self._create_video_per_part(exercises, template, 3) - - await self._document_store.save_to_db_with_id("speaking", template, req_id) - self._logger.info(f'Saved speaking to DB with id {req_id} : {str(template)}') - - async def _create_video_per_part(self, exercises: List[Dict], template: Dict, part: int): - avatar = (random.choice(list(ELAIAvatars))).name - template_index = part - 1 - - # Using list comprehension to find the element with the desired value in the 'type' field - found_exercises = [element for element in exercises if element.get('type') == part] - - # Check if any elements were found - if found_exercises: - exercise = found_exercises[0] - self._logger.info(f'Creating video for speaking part {part}') - if part in {1, 3}: - questions = [] - for question in exercise["questions"]: - result = await self._create_video( - question, - avatar, - f'Failed to create video for part {part} question: {str(exercise["question"])}' - ) - if result is not None: - video = { - "text": question, - "video_path": result["video_path"], - "video_url": result["video_url"] - } - questions.append(video) - - template["exercises"][template_index]["prompts"] = questions - if part == 1: - template["exercises"][template_index]["first_title"] = exercise["first_topic"] - template["exercises"][template_index]["second_title"] = exercise["second_topic"] - else: - template["exercises"][template_index]["title"] = exercise["topic"] - else: - result = await self._create_video( - exercise["question"], - avatar, - f'Failed to create video for part {part} question: {str(exercise["question"])}' - ) - if result is not None: - template["exercises"][template_index]["prompts"] = exercise["prompts"] - template["exercises"][template_index]["text"] = exercise["question"] - template["exercises"][template_index]["title"] = exercise["topic"] - template["exercises"][template_index]["video_url"] = result["video_url"] - template["exercises"][template_index]["video_path"] = result["video_path"] - - if not found_exercises: - template["exercises"].pop(template_index) - - return template - - async def generate_video( - self, part: int, avatar: str, topic: str, questions: list[str], - *, - second_topic: Optional[str] = None, - prompts: Optional[list[str]] = None, - suffix: Optional[str] = None, - ): - params = locals() - params.pop('self') - - request_id = str(uuid.uuid4()) - self._logger.info( - f'POST - generate_video_{part} - Received request to generate video {part}. ' - f'Use this id to track the logs: {request_id} - Request data: " + {params}' - ) - - part_questions = self._get_part_questions(part, questions, avatar) - videos = [] - - self._logger.info(f'POST - generate_video_{part} - {request_id} - Creating videos for speaking part {part}.') - for question in part_questions: - self._logger.info(f'POST - generate_video_{part} - {request_id} - Creating video for question: {question}') - result = await self._create_video( - question, - avatar, - 'POST - generate_video_{p} - {r} - Failed to create video for part {p} question: {q}'.format( - p=part, r=request_id, q=question - ) - ) - if result is not None: - self._logger.info(f'POST - generate_video_{part} - {request_id} - Video created') - self._logger.info( - f'POST - generate_video_{part} - {request_id} - Uploaded video to firebase: {result["video_url"]}' - ) - video = { - "text": question, - "video_path": result["video_path"], - "video_url": result["video_url"] - } - videos.append(video) - - if part == 2 and len(videos) == 0: - raise Exception(f'Failed to create video for part 2 question: {questions[0]}') - - return self._get_part_response(part, topic, videos, second_topic, prompts, suffix) - - @staticmethod - def _get_part_questions(part: int, questions: list[str], avatar: str): - part_questions: list[str] = [] - - if part == 1: - id_to_name = { - "5912afa7c77c47d3883af3d874047aaf": "MATTHEW", - "9e58d96a383e4568a7f1e49df549e0e4": "VERA", - "d2cdd9c0379a4d06ae2afb6e5039bd0c": "EDWARD", - "045cb5dcd00042b3a1e4f3bc1c12176b": "TANYA", - "1ae1e5396cc444bfad332155fdb7a934": "KAYLA", - "0ee6aa7cc1084063a630ae514fccaa31": "JEROME", - "5772cff935844516ad7eeff21f839e43": "TYLER", - - } - part_questions.extend( - [ - "Hello my name is " + id_to_name.get(avatar) + ", what is yours?", - "Do you work or do you study?", - *questions - ] - ) - elif part == 2: - # Removed as the examiner should not say what is on the card. - # question = question + " In your answer you should consider: " + " ".join(prompts) + suffix - part_questions.append(f'{questions[0]}\nYou have 1 minute to take notes.') - elif part == 3: - part_questions = questions - - return part_questions - - @staticmethod - def _get_part_response( - part: int, - topic: str, - videos: list[dict], - second_topic: Optional[str], - prompts: Optional[list[str]], - suffix: Optional[str] - ): - response = {} - if part == 1: - response = { - "prompts": videos, - "first_title": topic, - "second_title": second_topic, - "type": "interactiveSpeaking" - } - if part == 2: - response = { - "prompts": prompts, - "title": topic, - "suffix": suffix, - "type": "speaking", - # includes text, video_url and video_path - **videos[0] - } - if part == 3: - response = { - "prompts": videos, - "title": topic, - "type": "interactiveSpeaking", - } - - response["id"] = str(uuid.uuid4()) - return response - - async def _create_video(self, question: str, avatar: str, error_message: str): - result = await self._vid_gen.create_video(question, avatar) - if result is not None: - sound_file_path = FilePaths.VIDEO_FILES_PATH + result - firebase_file_path = FilePaths.FIREBASE_SPEAKING_VIDEO_FILES_PATH + result - url = await self._file_storage.upload_file_firebase_get_url(firebase_file_path, sound_file_path) - return { - "video_path": firebase_file_path, - "video_url": url - } - self._logger.error(error_message) - return None @staticmethod def _grade_template(): diff --git a/app/services/impl/third_parties/elai/__init__.py b/app/services/impl/third_parties/elai/__init__.py index 939b277..c74eac5 100644 --- a/app/services/impl/third_parties/elai/__init__.py +++ b/app/services/impl/third_parties/elai/__init__.py @@ -1,15 +1,8 @@ -import asyncio -import os -import logging -from asyncio import sleep from copy import deepcopy - -import aiofiles -from charset_normalizer.md import getLogger - +from logging import getLogger from httpx import AsyncClient -from app.configs.constants import ELAIAvatars +from app.dtos.video import Task, TaskStatus from app.services.abc import IVideoGeneratorService @@ -17,7 +10,9 @@ class ELAI(IVideoGeneratorService): _ELAI_ENDPOINT = 'https://apis.elai.io/api/v1/videos' - def __init__(self, client: AsyncClient, token: str, conf: dict): + def __init__(self, client: AsyncClient, token: str, avatars: dict, *, conf: dict): + super().__init__(deepcopy(avatars)) + self._http_client = client self._conf = deepcopy(conf) self._logger = getLogger(__name__) @@ -31,14 +26,13 @@ class ELAI(IVideoGeneratorService): "Authorization": f"Bearer {token}" } - async def create_video(self, text: str, avatar: str): - avatar_url = ELAIAvatars[avatar].value.get("avatar_url") - avatar_code = ELAIAvatars[avatar].value.get("avatar_code") - avatar_gender = ELAIAvatars[avatar].value.get("avatar_gender") - avatar_canvas = ELAIAvatars[avatar].value.get("avatar_canvas") - voice_id = ELAIAvatars[avatar].value.get("voice_id") - voice_provider = ELAIAvatars[avatar].value.get("voice_provider") + avatar_url = self._avatars[avatar].get("avatar_url") + avatar_code = self._avatars[avatar].get("avatar_code") + avatar_gender = self._avatars[avatar].get("avatar_gender") + avatar_canvas = self._avatars[avatar].get("avatar_canvas") + voice_id = self._avatars[avatar].get("voice_id") + voice_provider = self._avatars[avatar].get("voice_provider") self._conf["slides"][0]["canvas"]["objects"][0]["src"] = avatar_url self._conf["slides"]["avatar"] = { @@ -59,37 +53,32 @@ class ELAI(IVideoGeneratorService): if video_id: await self._http_client.post(f'{self._ELAI_ENDPOINT}/render/{video_id}', headers=self._GET_HEADER) + return Task( + result=video_id, + status=TaskStatus.STARTED, + ) + else: + return Task(status=TaskStatus.ERROR) - while True: - response = await self._http_client.get(f'{self._ELAI_ENDPOINT}/{video_id}', headers=self._GET_HEADER) - response_data = response.json() + async def pool_status(self, video_id: str) -> Task: + response = await self._http_client.get(f'{self._ELAI_ENDPOINT}/{video_id}', headers=self._GET_HEADER) + response_data = response.json() - if response_data['status'] == 'ready': - self._logger.info(response_data) - - download_url = response_data.get('url') - output_directory = 'download-video/' - output_filename = video_id + '.mp4' - - response = await self._http_client.get(download_url) - - if response.status_code == 200: - os.makedirs(output_directory, exist_ok=True) - output_path = os.path.join(output_directory, output_filename) - - with open(output_path, 'wb') as f: - f.write(response.content) - - self._logger.info(f"File '{output_filename}' downloaded successfully.") - return output_filename - - else: - self._logger.error(f"Failed to download file. Status code: {response.status_code}") - return None - - elif response_data['status'] == 'failed': - self._logger.error('Video creation failed.') - break - else: - self._logger.info('Video is still processing. Checking again in 10 seconds...') - await sleep(10) \ No newline at end of file + if response_data['status'] == 'ready': + self._logger.info(response_data) + return Task( + status=TaskStatus.COMPLETED, + result=response_data.get('url') + ) + elif response_data['status'] == 'failed': + self._logger.error('Video creation failed.') + return Task( + status=TaskStatus.ERROR, + result=response_data.get('url') + ) + else: + self._logger.info('Video is still processing.') + return Task( + status=TaskStatus.IN_PROGRESS, + result=video_id + ) diff --git a/app/services/impl/third_parties/elai/avatars.json b/app/services/impl/third_parties/elai/avatars.json new file mode 100644 index 0000000..16da66b --- /dev/null +++ b/app/services/impl/third_parties/elai/avatars.json @@ -0,0 +1,58 @@ +{ + "Gia": { + "avatar_code": "gia.business", + "avatar_gender": "female", + "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/gia/business/gia_business.png", + "avatar_canvas": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/gia/business/gia_business.png", + "voice_id": "EXAVITQu4vr4xnSDxMaL", + "voice_provider": "elevenlabs" + }, + "Vadim": { + "avatar_code": "vadim.business", + "avatar_gender": "male", + "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/vadim/business/vadim_business.png", + "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/vadim/business/vadim_business.png", + "voice_id": "flq6f7yk4E4fJM5XTYuZ", + "voice_provider": "elevenlabs" + }, + "Orhan": { + "avatar_code": "orhan.business", + "avatar_gender": "male", + "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/orhan/business/orhan.png", + "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/orhan/business/orhan.png", + "voice_id": "en-US-AndrewMultilingualNeural", + "voice_provider": "azure" + }, + "Flora": { + "avatar_code": "flora.business", + "avatar_gender": "female", + "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/flora/business/flora_business.png", + "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/flora/business/flora_business.png", + "voice_id": "en-US-JaneNeural", + "voice_provider": "azure" + }, + "Scarlett": { + "avatar_code": "scarlett.business", + "avatar_gender": "female", + "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/scarlett/business/scarlett_business.png", + "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/scarlett/business/scarlett_business.png", + "voice_id": "en-US-NancyNeural", + "voice_provider": "azure" + }, + "Parker": { + "avatar_code": "parker.casual", + "avatar_gender": "male", + "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/parker/casual/parker_casual.png", + "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/parker/casual/parker_casual.png", + "voice_id": "en-US-TonyNeural", + "voice_provider": "azure" + }, + "Ethan": { + "avatar_code": "ethan.business", + "avatar_gender": "male", + "avatar_url": "https://elai-avatars.s3.us-east-2.amazonaws.com/common/ethan/business/ethan_business_low.png", + "avatar_canvas": "https://d3u63mhbhkevz8.cloudfront.net/common/ethan/business/ethan_business_low.png", + "voice_id": "en-US-JasonNeural", + "voice_provider": "azure" + } +} diff --git a/app/services/impl/third_parties/heygen/__init__.py b/app/services/impl/third_parties/heygen/__init__.py new file mode 100644 index 0000000..c78fef9 --- /dev/null +++ b/app/services/impl/third_parties/heygen/__init__.py @@ -0,0 +1,93 @@ +import asyncio +import os +import logging +import random +from copy import deepcopy + +import aiofiles + +from httpx import AsyncClient + +from app.dtos.video import Task, TaskStatus +from app.services.abc import IVideoGeneratorService + + +class Heygen(IVideoGeneratorService): + + _GET_VIDEO_URL = 'https://api.heygen.com/v1/video_status.get' + + def __init__(self, client: AsyncClient, token: str, avatars: dict): + super().__init__(deepcopy(avatars)) + self._get_header = { + 'X-Api-Key': token + } + self._post_header = { + 'X-Api-Key': token, + 'Content-Type': 'application/json' + } + self._http_client = client + self._logger = logging.getLogger(__name__) + + async def create_video(self, text: str, avatar: str): + if not avatar: + random_avatar_name = random.choice(list(self._avatars.keys())) + avatar = self._avatars[random_avatar_name]["id"] + #["id"] + else: + avatar = self._avatars[avatar]["id"] + + create_video_url = f'https://api.heygen.com/v2/template/{avatar}/generate' + data = { + "test": False, + "caption": False, + "title": "video_title", + "variables": { + "script_here": { + "name": "script_here", + "type": "text", + "properties": { + "content": text + } + } + } + } + response = await self._http_client.post(create_video_url, headers=self._post_header, json=data) + self._logger.info(response.status_code) + self._logger.info(response.json()) + video_id = response.json()["data"]["video_id"] + + return Task( + result=video_id, + status=TaskStatus.STARTED, + ) + + + async def pool_status(self, video_id: str) -> Task: + response = await self._http_client.get(self._GET_VIDEO_URL, headers=self._get_header, params={ + 'video_id': video_id + }) + response_data = response.json() + + status = response_data["data"]["status"] + error = response_data["data"]["error"] + + if status != "completed" and error is None: + self._logger.info(f"Status: {status}") + return Task( + status=TaskStatus.IN_PROGRESS, + result=video_id + ) + + if error: + self._logger.error('Video creation failed.') + return Task( + status=TaskStatus.ERROR, + result=response_data.get('url') + ) + + url = response.json()['data']['video_url'] + self._logger.info(f'Successfully generated video: {url}') + return Task( + status=TaskStatus.COMPLETED, + result=url + ) diff --git a/app/services/impl/third_parties/heygen/avatars.json b/app/services/impl/third_parties/heygen/avatars.json new file mode 100644 index 0000000..8bc7140 --- /dev/null +++ b/app/services/impl/third_parties/heygen/avatars.json @@ -0,0 +1,30 @@ +{ + "Matthew Noah": { + "id": "5912afa7c77c47d3883af3d874047aaf", + "avatar_gender": "male" + }, + "Vera Cerise": { + "id": "9e58d96a383e4568a7f1e49df549e0e4", + "avatar_gender": "female" + }, + "Edward Tony": { + "id": "d2cdd9c0379a4d06ae2afb6e5039bd0c", + "avatar_gender": "male" + }, + "Tanya Molly": { + "id": "045cb5dcd00042b3a1e4f3bc1c12176b", + "avatar_gender": "female" + }, + "Kayla Abbi": { + "id": "1ae1e5396cc444bfad332155fdb7a934", + "avatar_gender": "female" + }, + "Jerome Ryan": { + "id": "0ee6aa7cc1084063a630ae514fccaa31", + "avatar_gender": "male" + }, + "Tyler Christopher": { + "id": "5772cff935844516ad7eeff21f839e43", + "avatar_gender": "male" + } +} \ No newline at end of file