From 78ce11a30db1a4506764a017b1c14a6c58102b6a Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 7 Jan 2025 15:45:19 -0500 Subject: [PATCH] Improved the video file clip function --- app/routers/hosted_file.py | 176 ++++++++++++++++++++++++++++++++++--- 1 file changed, 165 insertions(+), 11 deletions(-) diff --git a/app/routers/hosted_file.py b/app/routers/hosted_file.py index 64f8e4d..50d9860 100644 --- a/app/routers/hosted_file.py +++ b/app/routers/hosted_file.py @@ -592,7 +592,7 @@ async def upload_files( # ### BEGIN ### API Hosted File Route ### upload_files_fake() ### -# This just needs to return the currect model for a hosted_file +# This just needs to return the current model for a hosted_file # Everything else seems to be working well # Should this also do something with meta data and updating the DB? @router.post('/upload_files/fake') @@ -1256,14 +1256,14 @@ def run_ffmpeg(cmd): @router.post('/create_video') async def create_video( - file: UploadFile = File(...), - title_image: UploadFile = File(None), - title_part_1: str = Form(...), - title_part_2: str = Form(...), - subtitle_part_1: str = Form(...), - subtitle_part_2: str = Form(...), - font_color: str = Form('darkblue'), -): + file: UploadFile = File(...), + title_image: UploadFile = File(None), + title_part_1: str = Form(...), + title_part_2: str = Form(...), + subtitle_part_1: str = Form(...), + subtitle_part_2: str = Form(...), + font_color: str = Form('darkblue'), + ): log.setLevel(logging.DEBUG) log.debug(locals()) @@ -1359,9 +1359,163 @@ async def create_video( return FileResponse(video_name, media_type='video/mp4', filename=f'{title_part_1}_{subtitle_part_1}.mp4') - -@router.post('/clip_video') +# ### BEGIN ### API Hosted File ### clip_video() ### +# Updated 2025-01-07 +@router.get('/{hosted_file_id}/clip_video') async def clip_video( + hosted_file_id: str = Path(min_length=11, max_length=22), + + link_to_type: str = Query(..., min_length=2, max_length=50), + link_to_id: str = Query(..., min_length=11, max_length=22), + + filename_no_ext: str = Query('automated_hosted_file_clip_video', min_length=1, max_length=240), # Intentionally below 255 characters to account for the extension + + from_type: str = 'mp4', + to_type: str = 'mp4', + + start_time: str = Query(..., min_length=8, max_length=8), + end_time: str = Query(..., min_length=8, max_length=8), + reencode: bool = Query(False), + + commons: Common_Route_Params = Depends(common_route_params), + ): + log.setLevel(logging.DEBUG) + log.debug(locals()) + + account_id = commons.x_account_id + + if link_to_id := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type): pass + else: + return mk_resp(data=None, status_code=400, response=commons.response) + + # Need to look up file_hash for hosted_file_id + hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id) + file_hash = hosted_file_obj.hash_sha256 + + file_hash_filename = f'{file_hash}.file' + + hosted_files_path = settings.FILES_PATH['hosted_files_root'] + log.info(f'Hosted Files Path: {hosted_files_path}') + log.debug(shutil.disk_usage(hosted_files_path)) + + file_subdirectory = file_hash[0:2] + full_file_path = os.path.join(hosted_files_path, file_subdirectory, file_hash_filename) + log.info(f'File Hash with Subdirectory: {full_file_path}') + + hosted_tmp_path = settings.FILES_PATH['hosted_tmp_root'] + log.info(f'Hosted Tmp Path: {hosted_tmp_path}') + log.debug(shutil.disk_usage(hosted_tmp_path)) + + hosted_tmp_convert_file_path = os.path.join(hosted_tmp_path, 'convert_file') + if pathlib.Path(hosted_tmp_convert_file_path): + log.info('Hosted tmp convert file path found') + else: + log.info('Creating hosted tmp convert file path') + pathlib.Path(hosted_tmp_convert_file_path).mkdir(parents=True, exist_ok=True) + + # Run the ffmpeg command to clip a video file based on the start and end times given + log.info('Run the ffmpeg command to clip a video file based on the start and end times given') + + hosted_file_dict = None + with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_video_file_clip: + tmp_video_file_clip_path = tmp_video_file_clip.name + + # NOTE: It seems very important that the -y argument is used with ffmpeg run by subprocess.run(). Otherwise the process will hang. + # NOTE: This is a blocking process. It will take a while to complete. + if reencode: + new_video_file_clip_filename = f'{filename_no_ext}_[clip_reencode].{to_type}' + log.debug(new_video_file_clip_filename) + + cmd = f"ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -c:v libx264 -crf 23 -maxrate 2M -bufsize 2M -c:a copy -movflags +faststart {tmp_video_file_clip_path}" + else: + new_video_file_clip_filename = f'{filename_no_ext}_[clip].{to_type}' + log.debug(new_video_file_clip_filename) + + cmd = f"ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -c:v copy -c:a copy -movflags +faststart {tmp_video_file_clip_path}" + + log.debug(cmd) + + # Run the ffmpeg command + args = shlex.split(cmd) + try: + result = subprocess.run(args, check=True, capture_output=True, text=True) + log.debug(result.stdout) + except subprocess.CalledProcessError as e: + log.exception('Error running ffmpeg command') + return {'success': False, 'status_message': f'Error running ffmpeg command: {e}'} + + + # *** Part 2: *** Save the converted hashed file to hosted_files directory. + + file_info = await save_file_to_hosted_file( + file_path = tmp_video_file_clip_path, + filename = new_video_file_clip_filename, + extension = to_type, + account_id = account_id, + link_to_type = link_to_type, + link_to_id = link_to_id, + ) + + # *** Part 3: *** Save information to database in hosted_file table (hosted_file_link table will be updated by an event_file table trigger) + + new_hosted_file_id = None + if file_info.get('saved'): + # NOTE: Just in case look up in DB based on hash + log.info('Look up in DB based on hash...') + if hosted_file_sel_result := sql_select( + table_name = 'hosted_file', + field_name = 'hash_sha256', + field_value = file_info['hash_sha256'], + ): + log.warning('Found an existing host_file object_entry in the DB but the file was not found on the server!') + # Got existing host_file object_entry! + # Odd... the hash was found in the database, but the file had to be copied again. + # If this happens then the file on the host server was probably deleted at some point. + new_hosted_file_id = hosted_file_sel_result.get('id', None) + # hosted_file_id_random = hosted_file_sel_result.get('id_random', None) + hosted_file_dict = load_hosted_file_obj(hosted_file_id=new_hosted_file_id, model_as_dict=True) + else: + # This is normal since the file was not found on the host server and not found in the DB. + # Create a new host_file object entry and new host_file.id_random. + file_info['account_id'] = account_id + # file_info['account_id_random'] = account_id_random + hosted_file_obj = Hosted_File_Base(**file_info) + if hosted_file_obj_result := create_hosted_file_obj(hosted_file_obj_new=hosted_file_obj): + new_hosted_file_id = hosted_file_obj_result + hosted_file_dict = load_hosted_file_obj(hosted_file_id=new_hosted_file_id, model_as_dict=True) + else: + log.warning('For some reason a host_file object entry could not be created.') + new_hosted_file_id = None + hosted_file_dict = hosted_file_obj.dict(by_alias=True, exclude_unset=True, exclude={'id', 'id_random'}) # pylint: disable=no-member + log.debug(hosted_file_obj_result) + + # NOTE: Currently sql_insert does not handle all successful inserts correctly. If there is not an autonum ID then it will return 0 as the ID. + if link_to_type in ['event', 'event_location', 'event_session', 'event_presentation', 'event_presenter', 'event_badge', 'event_exhibit', 'event_person']: + log.info('File is for event module. Trigger will create the hosted_file_link record.') + elif create_hosted_file_link( + account_id = account_id, + hosted_file_id = new_hosted_file_id, # This for the new file created + link_to_type = link_to_type, + link_to_id = link_to_id, + ): + log.info('The hosted file link was created.') + pass # This if statement should be improved + else: + # This if statement should be improved + log.debug('Because the hosted_file_link table does not have a primary autonum this check is incorrect even when successful.') + log.debug('Something may have gone wrong while trying to create the hosted_file_link record.') + log.debug('The hosted_file_link was probably created fine though.') + log.debug(hosted_file_sel_result) + else: return False + + log.debug(hosted_file_dict) + return mk_resp(data=hosted_file_dict, response=commons.response) +# ### END ### API Hosted File ### clip_video() ### + + +# Updated 2024-01-01 +@router.post('/clip_video_v1') +async def clip_video_v1( video_file: UploadFile = File(...), start_time: str = Form(...), end_time: str = Form(...),