diff --git a/.gitignore b/.gitignore index 6aa0ca7..d8bbe40 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ node_modules ./models/* venv/ -.venv \ No newline at end of file +.venv +/.vs diff --git a/README-en.md b/README-en.md index 7c16f2d..7c07fa2 100644 --- a/README-en.md +++ b/README-en.md @@ -386,4 +386,4 @@ Click to view the [`LICENSE`](LICENSE) file ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) \ No newline at end of file diff --git a/app/controllers/v1/video.py b/app/controllers/v1/video.py index e80d762..310ad01 100644 --- a/app/controllers/v1/video.py +++ b/app/controllers/v1/video.py @@ -4,7 +4,7 @@ import pathlib import shutil from typing import Union -from fastapi import BackgroundTasks, Depends, Path, Request, UploadFile +from fastapi import BackgroundTasks, Depends, Path, Query, Request, UploadFile from fastapi.params import File from fastapi.responses import FileResponse, StreamingResponse from loguru import logger @@ -41,7 +41,10 @@ _redis_db = config.app.get("redis_db", 0) _redis_password = config.app.get("redis_password", None) _max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5) -redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}" +if _redis_password: + redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}" +else: + redis_url = f"redis://{_redis_host}:{_redis_port}/{_redis_db}" # 根据配置选择合适的任务管理器 if _enable_redis: task_manager = RedisTaskManager( @@ -94,8 +97,6 @@ def create_task( task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}" ) -from fastapi import Query - @router.get("/tasks", response_model=TaskQueryResponse, summary="Get all tasks") def get_all_tasks(request: Request, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1)): request_id = base.get_task_id(request) @@ -131,7 +132,7 @@ def get_task( def file_to_uri(file): if not file.startswith(endpoint): - _uri_path = v.replace(task_dir, "tasks").replace("\\", "/") + _uri_path = file.replace(task_dir, "tasks").replace("\\", "/") _uri_path = f"{endpoint}/{_uri_path}" else: _uri_path = file @@ -227,20 +228,44 @@ def upload_bgm_file(request: Request, file: UploadFile = File(...)): async def stream_video(request: Request, file_path: str): tasks_dir = utils.task_dir() video_path = os.path.join(tasks_dir, file_path) + + # Check if the file exists + if not os.path.exists(video_path): + raise HttpException( + "", status_code=404, message=f"File not found: {file_path}" + ) + range_header = request.headers.get("Range") video_size = os.path.getsize(video_path) start, end = 0, video_size - 1 length = video_size if range_header: - range_ = range_header.split("bytes=")[1] - start, end = [int(part) if part else None for part in range_.split("-")] - if start is None: - start = video_size - end - end = video_size - 1 - if end is None: - end = video_size - 1 - length = end - start + 1 + try: + range_ = range_header.split("bytes=")[1] + start, end = [int(part) if part else None for part in range_.split("-")] + + if start is None and end is not None: + # Format: bytes=-N (last N bytes) + start = max(0, video_size - end) + end = video_size - 1 + elif end is None: + # Format: bytes=N- (from byte N to the end) + end = video_size - 1 + + # Ensure values are within valid range + start = max(0, min(start, video_size - 1)) + end = min(end, video_size - 1) + + if start > end: + # Invalid range, serve entire file + start, end = 0, video_size - 1 + + length = end - start + 1 + except (ValueError, IndexError): + # On parsing error, serve entire content + start, end = 0, video_size - 1 + length = video_size def file_iterator(file_path, offset=0, bytes_to_read=None): with open(file_path, "rb") as f: @@ -258,30 +283,54 @@ async def stream_video(request: Request, file_path: str): file_iterator(video_path, start, length), media_type="video/mp4" ) response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}" - response.headers["Accept-Ranges"] = "bytes" - response.headers["Content-Length"] = str(length) - response.status_code = 206 # Partial Content - return response - @router.get("/download/{file_path:path}") -async def download_video(_: Request, file_path: str): +async def download_video(request: Request, file_path: str): """ download video - :param _: Request request + :param request: Request request :param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4 :return: video file """ - tasks_dir = utils.task_dir() - video_path = os.path.join(tasks_dir, file_path) - file_path = pathlib.Path(video_path) - filename = file_path.stem - extension = file_path.suffix - headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"} - return FileResponse( - path=video_path, - headers=headers, - filename=f"{filename}{extension}", - media_type=f"video/{extension[1:]}", - ) + try: + tasks_dir = utils.task_dir() + video_path = os.path.join(tasks_dir, file_path) + + # Check if the file exists + if not os.path.exists(video_path): + raise HttpException( + "", status_code=404, message=f"File not found: {file_path}" + ) + + # Check if the file is readable + if not os.access(video_path, os.R_OK): + logger.error(f"File not readable: {video_path}") + raise HttpException( + "", status_code=403, message=f"File not accessible: {file_path}" + ) + + # Get the filename and extension + path_obj = pathlib.Path(video_path) + filename = path_obj.stem + extension = path_obj.suffix + + # Determine appropriate media type + media_type = "application/octet-stream" + if extension.lower() in ['.mp4', '.webm']: + media_type = f"video/{extension[1:]}" + + headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"} + + logger.info(f"Sending file: {video_path}, size: {os.path.getsize(video_path)}") + return FileResponse( + path=video_path, + headers=headers, + filename=f"{filename}{extension}", + media_type=media_type, + ) + except Exception as e: + logger.exception(f"Error downloading file: {str(e)}") + raise HttpException( + "", status_code=500, message=f"Failed to download file: {str(e)}" + ) diff --git a/app/services/utils/video_effects.py b/app/services/utils/video_effects.py index 6cba8eb..62e8cdb 100644 --- a/app/services/utils/video_effects.py +++ b/app/services/utils/video_effects.py @@ -1,6 +1,5 @@ from moviepy import Clip, vfx - # FadeIn def fadein_transition(clip: Clip, t: float) -> Clip: return clip.with_effects([vfx.FadeIn(t)]) diff --git a/app/services/video.py b/app/services/video.py index 1a79e30..305acda 100644 --- a/app/services/video.py +++ b/app/services/video.py @@ -4,7 +4,9 @@ import os import random import gc import shutil +import uuid from typing import List +import multiprocessing from loguru import logger from moviepy import ( AudioFileClip, @@ -18,7 +20,8 @@ from moviepy import ( concatenate_videoclips, ) from moviepy.video.tools.subtitles import SubtitlesClip -from PIL import ImageFont +from moviepy.video.io.ffmpeg_writer import FFMPEG_VideoWriter +from PIL import Image, ImageEnhance, ImageFont from app.models import const from app.models.schema import ( @@ -47,45 +50,135 @@ class SubClippedVideoClip: return f"SubClippedVideoClip(file_path={self.file_path}, start_time={self.start_time}, end_time={self.end_time}, duration={self.duration}, width={self.width}, height={self.height})" +# Improved video quality settings audio_codec = "aac" video_codec = "libx264" fps = 30 +video_bitrate = "25M" +audio_bitrate = "320k" +crf = "15" +preset = "slower" + +def get_optimal_encoding_params(width, height, content_type="video"): + """Get optimal encoding parameters based on resolution and content type.""" + pixels = width * height + + # Adjust settings based on resolution and content + if content_type == "image": + # Images need higher quality settings + if pixels >= 1920 * 1080: # 1080p+ + return {"crf": "12", "bitrate": "35M", "preset": "slower"} + elif pixels >= 1280 * 720: # 720p+ + return {"crf": "16", "bitrate": "30M", "preset": "slower"} + else: + return {"crf": "18", "bitrate": "25M", "preset": "slow"} + else: + # Regular video content + if pixels >= 1920 * 1080: # 1080p+ + return {"crf": "18", "bitrate": "30M", "preset": "slower"} + elif pixels >= 1280 * 720: # 720p+ + return {"crf": "20", "bitrate": "25M", "preset": "slower"} + else: + return {"crf": "22", "bitrate": "20M", "preset": "slow"} + +def get_standard_ffmpeg_params(width, height, content_type="video"): + """Get standardized FFmpeg parameters for consistent quality.""" + params = get_optimal_encoding_params(width, height, content_type) + if content_type == "image" or (width * height >= 1920 * 1080): + # Use higher quality for images and high-res content + pix_fmt = "yuv444p" + else: + # Use more compatible format for standard video + pix_fmt = "yuv420p" + + return [ + "-crf", params["crf"], + "-preset", params["preset"], + "-profile:v", "high", + "-level", "4.1", + "-x264-params", "keyint=60:min-keyint=60:scenecut=0:ref=3:bframes=3:b-adapt=2:direct=auto:me=umh:subme=8:trellis=2:aq-mode=2", + "-pix_fmt", pix_fmt, + "-movflags", "+faststart", + "-tune", "film", + "-colorspace", "bt709", + "-color_primaries", "bt709", + "-color_trc", "bt709", + "-color_range", "tv", + "-bf", "5", # More B-frames for better compression + "-g", "60", # GOP size + "-qmin", "10", # Minimum quantizer + "-qmax", "51", # Maximum quantizer + "-qdiff", "4", # Max difference between quantizers + "-sc_threshold", "40", # Scene change threshold + "-flags", "+cgop+mv4" # Additional encoding flags + ] + +def ensure_even_dimensions(width, height): + """Ensure dimensions are even numbers (required for h264).""" + width = width if width % 2 == 0 else width - 1 + height = height if height % 2 == 0 else height - 1 + return width, height def close_clip(clip): if clip is None: return try: - # close main resources - if hasattr(clip, 'reader') and clip.reader is not None: - clip.reader.close() - - # close audio resources - if hasattr(clip, 'audio') and clip.audio is not None: - if hasattr(clip.audio, 'reader') and clip.audio.reader is not None: - clip.audio.reader.close() - del clip.audio - - # close mask resources - if hasattr(clip, 'mask') and clip.mask is not None: - if hasattr(clip.mask, 'reader') and clip.mask.reader is not None: - clip.mask.reader.close() - del clip.mask - - # handle child clips in composite clips + # handle child clips in composite clips first if hasattr(clip, 'clips') and clip.clips: for child_clip in clip.clips: if child_clip is not clip: # avoid possible circular references close_clip(child_clip) + + # close audio resources with better error handling + if hasattr(clip, 'audio') and clip.audio is not None: + if hasattr(clip.audio, 'reader') and clip.audio.reader is not None: + try: + # Check if the reader is still valid before closing + if hasattr(clip.audio.reader, 'proc') and clip.audio.reader.proc is not None: + if clip.audio.reader.proc.poll() is None: + clip.audio.reader.close() + else: + clip.audio.reader.close() + except (OSError, AttributeError): + # Handle invalid handles and missing attributes + pass + clip.audio = None + + # close mask resources + if hasattr(clip, 'mask') and clip.mask is not None: + if hasattr(clip.mask, 'reader') and clip.mask.reader is not None: + try: + clip.mask.reader.close() + except (OSError, AttributeError): + pass + clip.mask = None + + # close main resources + if hasattr(clip, 'reader') and clip.reader is not None: + try: + clip.reader.close() + except (OSError, AttributeError): + pass # clear clip list if hasattr(clip, 'clips'): clip.clips = [] + # call clip's own close method if it exists + if hasattr(clip, 'close'): + try: + clip.close() + except (OSError, AttributeError): + pass + except Exception as e: logger.error(f"failed to close clip: {str(e)}") - del clip + try: + del clip + except: + pass gc.collect() def delete_files(files: List[str] | str): @@ -94,9 +187,10 @@ def delete_files(files: List[str] | str): for file in files: try: - os.remove(file) - except: - pass + if os.path.exists(file): + os.remove(file) + except Exception as e: + logger.debug(f"failed to delete file {file}: {str(e)}") def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""): if not bgm_type: @@ -109,11 +203,11 @@ def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""): suffix = "*.mp3" song_dir = utils.song_dir() files = glob.glob(os.path.join(song_dir, suffix)) - return random.choice(files) + if files: + return random.choice(files) return "" - def combine_videos( combined_video_path: str, video_paths: List[str], @@ -122,23 +216,25 @@ def combine_videos( video_concat_mode: VideoConcatMode = VideoConcatMode.random, video_transition_mode: VideoTransitionMode = None, max_clip_duration: int = 5, - threads: int = 2, + #threads: int = 2, + threads = min(multiprocessing.cpu_count(), 6), ) -> str: audio_clip = AudioFileClip(audio_file) audio_duration = audio_clip.duration logger.info(f"audio duration: {audio_duration} seconds") # Required duration of each clip - req_dur = audio_duration / len(video_paths) - req_dur = max_clip_duration - logger.info(f"maximum clip duration: {req_dur} seconds") + req_dur = min(audio_duration / len(video_paths), max_clip_duration) + logger.info(f"calculated clip duration: {req_dur} seconds") output_dir = os.path.dirname(combined_video_path) aspect = VideoAspect(video_aspect) video_width, video_height = aspect.to_resolution() + video_width, video_height = ensure_even_dimensions(video_width, video_height) processed_clips = [] subclipped_items = [] video_duration = 0 + for video_path in video_paths: clip = VideoFileClip(video_path) clip_duration = clip.duration @@ -150,7 +246,7 @@ def combine_videos( while start_time < clip_duration: end_time = min(start_time + max_clip_duration, clip_duration) if clip_duration - start_time >= max_clip_duration: - subclipped_items.append(SubClippedVideoClip(file_path= video_path, start_time=start_time, end_time=end_time, width=clip_w, height=clip_h)) + subclipped_items.append(SubClippedVideoClip(file_path=video_path, start_time=start_time, end_time=end_time, width=clip_w, height=clip_h)) start_time = end_time if video_concat_mode.value == VideoConcatMode.sequential.value: break @@ -173,14 +269,16 @@ def combine_videos( clip_duration = clip.duration # Not all videos are same size, so we need to resize them clip_w, clip_h = clip.size + if clip_w != video_width or clip_h != video_height: clip_ratio = clip.w / clip.h video_ratio = video_width / video_height logger.debug(f"resizing clip, source: {clip_w}x{clip_h}, ratio: {clip_ratio:.2f}, target: {video_width}x{video_height}, ratio: {video_ratio:.2f}") - if clip_ratio == video_ratio: + if abs(clip_ratio - video_ratio) < 0.01: # Almost same ratio clip = clip.resized(new_size=(video_width, video_height)) else: + # Use better scaling algorithm for quality if clip_ratio > video_ratio: scale_factor = video_width / clip_w else: @@ -188,13 +286,16 @@ def combine_videos( new_width = int(clip_w * scale_factor) new_height = int(clip_h * scale_factor) + + # Ensure dimensions are even numbers + new_width, new_height = ensure_even_dimensions(new_width, new_height) background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration(clip_duration) clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center") clip = CompositeVideoClip([background, clip_resized]) shuffle_side = random.choice(["left", "right", "top", "bottom"]) - if video_transition_mode.value == VideoTransitionMode.none.value: + if video_transition_mode is None or video_transition_mode.value == VideoTransitionMode.none.value: clip = clip elif video_transition_mode.value == VideoTransitionMode.fade_in.value: clip = video_effects.fadein_transition(clip, 1) @@ -217,14 +318,24 @@ def combine_videos( if clip.duration > max_clip_duration: clip = clip.subclipped(0, max_clip_duration) - # wirte clip to temp file + # Write clip to temp file with improved quality settings clip_file = f"{output_dir}/temp-clip-{i+1}.mp4" - clip.write_videofile(clip_file, logger=None, fps=fps, codec=video_codec) + encoding_params = get_optimal_encoding_params(video_width, video_height, "video") + clip.write_videofile(clip_file, + logger=None, + fps=fps, + codec=video_codec, + # Remove bitrate parameter as it conflicts with CRF in ffmpeg_params + ffmpeg_params=get_standard_ffmpeg_params(video_width, video_height, "video") + ) + + # Store clip duration before closing + clip_duration_value = clip.duration close_clip(clip) - processed_clips.append(SubClippedVideoClip(file_path=clip_file, duration=clip.duration, width=clip_w, height=clip_h)) - video_duration += clip.duration + processed_clips.append(SubClippedVideoClip(file_path=clip_file, duration=clip_duration_value, width=clip_w, height=clip_h)) + video_duration += clip_duration_value except Exception as e: logger.error(f"failed to process clip: {str(e)}") @@ -250,62 +361,62 @@ def combine_videos( if len(processed_clips) == 1: logger.info("using single clip directly") shutil.copy(processed_clips[0].file_path, combined_video_path) - delete_files(processed_clips) + delete_files([clip.file_path for clip in processed_clips]) logger.info("video combining completed") return combined_video_path - # create initial video file as base - base_clip_path = processed_clips[0].file_path - temp_merged_video = f"{output_dir}/temp-merged-video.mp4" - temp_merged_next = f"{output_dir}/temp-merged-next.mp4" - - # copy first clip as initial merged video - shutil.copy(base_clip_path, temp_merged_video) - - # merge remaining video clips one by one - for i, clip in enumerate(processed_clips[1:], 1): - logger.info(f"merging clip {i}/{len(processed_clips)-1}, duration: {clip.duration:.2f}s") + try: + # Load all processed clips + video_clips = [] + for clip_info in processed_clips: + try: + clip = VideoFileClip(clip_info.file_path) + if clip.duration > 0 and hasattr(clip, 'size') and None not in clip.size: + video_clips.append(clip) + else: + logger.warning(f"Skipping invalid clip: {clip_info.file_path}") + close_clip(clip) + except Exception as e: + logger.error(f"Failed to load clip {clip_info.file_path}: {str(e)}") + + if not video_clips: + logger.error("No valid clips could be loaded for final concatenation") + return "" + + # Concatenate all clips at once with compose method for better quality + logger.info(f"Concatenating {len(video_clips)} clips in a single operation") + final_clip = concatenate_videoclips(video_clips, method="compose") - try: - # load current base video and next clip to merge - base_clip = VideoFileClip(temp_merged_video) - next_clip = VideoFileClip(clip.file_path) + # Write the final result directly + encoding_params = get_optimal_encoding_params(video_width, video_height, "video") + logger.info(f"Writing final video with quality settings: CRF {encoding_params['crf']}, preset {encoding_params['preset']}") + + final_clip.write_videofile( + combined_video_path, + threads=threads, + logger=None, + temp_audiofile_path=os.path.dirname(combined_video_path), + audio_codec=audio_codec, + fps=fps, + ffmpeg_params=get_standard_ffmpeg_params(video_width, video_height, "video") + ) + + # Close all clips + close_clip(final_clip) + for clip in video_clips: + close_clip(clip) - # merge these two clips - merged_clip = concatenate_videoclips([base_clip, next_clip]) - - # save merged result to temp file - merged_clip.write_videofile( - filename=temp_merged_next, - threads=threads, - logger=None, - temp_audiofile_path=output_dir, - audio_codec=audio_codec, - fps=fps, - ) - close_clip(base_clip) - close_clip(next_clip) - close_clip(merged_clip) - - # replace base file with new merged file - delete_files(temp_merged_video) - os.rename(temp_merged_next, temp_merged_video) - - except Exception as e: - logger.error(f"failed to merge clip: {str(e)}") - continue - - # after merging, rename final result to target file name - os.rename(temp_merged_video, combined_video_path) - - # clean temp files - clip_files = [clip.file_path for clip in processed_clips] - delete_files(clip_files) - - logger.info("video combining completed") + logger.info("Video combining completed successfully") + + except Exception as e: + logger.error(f"Error during final video concatenation: {str(e)}") + finally: + # Clean up temp files + clip_files = [clip.file_path for clip in processed_clips] + delete_files(clip_files) + return combined_video_path - def wrap_text(text, max_width, font="Arial", fontsize=60): # Create ImageFont font = ImageFont.truetype(font, fontsize) @@ -359,7 +470,6 @@ def wrap_text(text, max_width, font="Arial", fontsize=60): height = len(_wrapped_lines_) * height return result, height - def generate_video( video_path: str, audio_path: str, @@ -369,6 +479,7 @@ def generate_video( ): aspect = VideoAspect(params.video_aspect) video_width, video_height = aspect.to_resolution() + video_width, video_height = ensure_even_dimensions(video_width, video_height) logger.info(f"generating video: {video_width} x {video_height}") logger.info(f" ① video: {video_path}") @@ -410,8 +521,8 @@ def generate_video( bg_color=params.text_background_color, stroke_color=params.stroke_color, stroke_width=params.stroke_width, - # interline=interline, - # size=size, + interline=interline, + size=size, ) duration = subtitle_item[0][1] - subtitle_item[0][0] _clip = _clip.with_start(subtitle_item[0][0]) @@ -472,60 +583,227 @@ def generate_video( logger.error(f"failed to add bgm: {str(e)}") video_clip = video_clip.with_audio(audio_clip) - video_clip.write_videofile( - output_file, - audio_codec=audio_codec, - temp_audiofile_path=output_dir, - threads=params.n_threads or 2, - logger=None, - fps=fps, - ) - video_clip.close() - del video_clip + + # Use improved encoding settings + try: + # Get optimized encoding parameters + encoding_params = get_optimal_encoding_params(video_width, video_height, "video") + ffmpeg_params = get_standard_ffmpeg_params(video_width, video_height, "video") + + # For Windows, use a simpler approach to avoid path issues with two-pass encoding + if os.name == 'nt': + # Single pass with high quality settings + video_clip.write_videofile( + output_file, + codec=video_codec, + audio_codec=audio_codec, + temp_audiofile_path=output_dir, + threads=params.n_threads or 2, + logger=None, + fps=fps, + ffmpeg_params=ffmpeg_params + ) + else: + # On Unix systems, we can use two-pass encoding more reliably + # Prepare a unique passlogfile name to avoid conflicts + passlog_id = str(uuid.uuid4())[:8] + passlogfile = os.path.join(output_dir, f"ffmpeg2pass_{passlog_id}") + + # Create a temporary file for first pass output + temp_first_pass = os.path.join(output_dir, f"temp_first_pass_{passlog_id}.mp4") + + # Flag to track if we should do second pass + do_second_pass = True + + # First pass parameters with explicit passlogfile + first_pass_params = ffmpeg_params + [ + "-pass", "1", + "-passlogfile", passlogfile, + "-an" # No audio in first pass + ] + + logger.info("Starting first pass encoding...") + try: + video_clip.write_videofile( + temp_first_pass, # Write to temporary file instead of null + codec=video_codec, + audio=False, # Skip audio processing in first pass + threads=params.n_threads or 2, + logger=None, + fps=fps, + ffmpeg_params=first_pass_params + ) + except Exception as e: + # If first pass fails, fallback to single-pass encoding + logger.warning(f"First pass encoding failed: {e}. Falling back to single-pass encoding.") + video_clip.write_videofile( + output_file, + codec=video_codec, + audio_codec=audio_codec, + temp_audiofile_path=output_dir, + threads=params.n_threads or 2, + logger=None, + fps=fps, + ffmpeg_params=ffmpeg_params + ) + do_second_pass = False + finally: + # Clean up first pass temporary file + if os.path.exists(temp_first_pass): + try: + os.remove(temp_first_pass) + except Exception as e: + logger.warning(f"Failed to delete temporary first pass file: {e}") + + # Second pass only if first pass succeeded + if do_second_pass: + logger.info("Starting second pass encoding...") + second_pass_params = ffmpeg_params + [ + "-pass", "2", + "-passlogfile", passlogfile + ] + video_clip.write_videofile( + output_file, + codec=video_codec, + audio_codec=audio_codec, + temp_audiofile_path=output_dir, + threads=params.n_threads or 2, + logger=None, + fps=fps, + ffmpeg_params=second_pass_params + ) + + # Clean up pass log files + for f in glob.glob(f"{passlogfile}*"): + try: + os.remove(f) + except Exception as e: + logger.warning(f"Failed to delete pass log file {f}: {e}") + finally: + # Ensure all resources are properly closed + close_clip(video_clip) + close_clip(audio_clip) + if 'bgm_clip' in locals(): + close_clip(bgm_clip) + # Force garbage collection + gc.collect() - -def preprocess_video(materials: List[MaterialInfo], clip_duration=4): +def preprocess_video(materials: List[MaterialInfo], clip_duration=4, apply_denoising=False): for material in materials: if not material.url: continue ext = utils.parse_extension(material.url) + + # First load the clip try: clip = VideoFileClip(material.url) except Exception: clip = ImageClip(material.url) + + # Then apply denoising if needed and it's a video + if ext not in const.FILE_TYPE_IMAGES and apply_denoising: + # Apply subtle denoising to video clips that might benefit + from moviepy.video.fx.all import denoise + + try: + # Get a sample frame to analyze noise level + frame = clip.get_frame(0) + import numpy as np + noise_estimate = np.std(frame) + + # Apply denoising only if noise level seems high + if noise_estimate > 15: # Threshold determined empirically + logger.info(f"Applying denoising to video with estimated noise: {noise_estimate:.2f}") + clip = denoise(clip, sigma=1.5, mode="fast") + except Exception as e: + logger.warning(f"Denoising attempt failed: {e}") width = clip.size[0] height = clip.size[1] - if width < 480 or height < 480: - logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required") - continue + + # Improved resolution check + min_resolution = 480 + # Calculate aspect ratio outside of conditional blocks so it's always defined + aspect_ratio = width / height + + if width < min_resolution or height < min_resolution: + logger.warning(f"Low resolution material: {width}x{height}, minimum {min_resolution}x{min_resolution} recommended") + # Instead of skipping, apply upscaling for very low-res content + if width < min_resolution/2 or height < min_resolution/2: + logger.warning("Resolution too low, skipping") + close_clip(clip) + continue + else: + # Apply high-quality upscaling for borderline content + logger.info(f"Applying high-quality upscaling to low-resolution content: {width}x{height}") + + # Calculate target dimensions while maintaining aspect ratio + if width < height: + new_width = min_resolution + new_height = int(new_width / aspect_ratio) + else: + new_height = min_resolution + new_width = int(new_height * aspect_ratio) + + # Ensure dimensions are even + new_width, new_height = ensure_even_dimensions(new_width, new_height) + + # Use high-quality scaling + clip = clip.resized(new_size=(new_width, new_height), resizer='lanczos') if ext in const.FILE_TYPE_IMAGES: logger.info(f"processing image: {material.url}") - # Create an image clip and set its duration to 3 seconds + + # Ensure dimensions are even numbers and enhance for better quality + width, height = ensure_even_dimensions(width, height) + + # Use higher resolution multiplier for sharper output + quality_multiplier = 1.2 if width < 1080 else 1.0 + enhanced_width = int(width * quality_multiplier) + enhanced_height = int(height * quality_multiplier) + enhanced_width, enhanced_height = ensure_even_dimensions(enhanced_width, enhanced_height) + + # Close the original clip before creating a new one to avoid file handle conflicts + close_clip(clip) + + # Create a new ImageClip with the image clip = ( ImageClip(material.url) + .resized(new_size=(enhanced_width, enhanced_height), resizer='bicubic') # Use bicubic for better quality .with_duration(clip_duration) .with_position("center") ) - # Apply a zoom effect using the resize method. - # A lambda function is used to make the zoom effect dynamic over time. - # The zoom effect starts from the original size and gradually scales up to 120%. - # t represents the current time, and clip.duration is the total duration of the clip (3 seconds). - # Note: 1 represents 100% size, so 1.2 represents 120% size. + # More subtle and smoother zoom effect zoom_clip = clip.resized( - lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration) + lambda t: 1 + (0.05 * (t / clip.duration)), # Reduced zoom from 0.1 to 0.05 for smoother effect + resizer='lanczos' # Ensure high-quality scaling ) - # Optionally, create a composite video clip containing the zoomed clip. - # This is useful when you want to add other elements to the video. + # Create composite with enhanced quality final_clip = CompositeVideoClip([zoom_clip]) - # Output the video to a file. + # Output with maximum quality settings video_file = f"{material.url}.mp4" - final_clip.write_videofile(video_file, fps=30, logger=None) + encoding_params = get_optimal_encoding_params(enhanced_width, enhanced_height, "image") + + final_clip.write_videofile(video_file, + fps=fps, + logger='bar', + codec=video_codec, + # Remove bitrate parameter as it conflicts with CRF in ffmpeg_params + ffmpeg_params=get_standard_ffmpeg_params(enhanced_width, enhanced_height, "image"), + write_logfile=False, + verbose=False + ) + + # Close all clips to properly release resources + close_clip(final_clip) + close_clip(zoom_clip) close_clip(clip) material.url = video_file - logger.success(f"image processed: {video_file}") + logger.success(f"high-quality image processed: {video_file}") + else: + close_clip(clip) + return materials \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a1731f6..0083173 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ moviepy==2.1.2 +Pillow streamlit==1.45.0 edge_tts==6.1.19 fastapi==0.115.6 uvicorn==0.32.1 openai==1.56.1 -faster-whisper==1.1.0 +faster-whisper loguru==0.7.3 google.generativeai==0.8.3 dashscope==1.20.14 @@ -14,3 +15,5 @@ redis==5.2.0 python-multipart==0.0.19 pyyaml requests>=2.31.0 +numpy +shutil diff --git a/webui.bat b/webui.bat index fd97514..8c8e08a 100644 --- a/webui.bat +++ b/webui.bat @@ -3,5 +3,12 @@ set CURRENT_DIR=%CD% echo ***** Current directory: %CURRENT_DIR% ***** set PYTHONPATH=%CURRENT_DIR% -rem set HF_ENDPOINT=https://hf-mirror.com +rem Activate Python virtual environment if exists +if exist "venv\Scripts\activate.bat" ( + call venv\Scripts\activate.bat +) + +rem Optional Hugging Face mirror setting +rem set HF_ENDOINT=https://hf-mirror.com + streamlit run .\webui\Main.py --browser.gatherUsageStats=False --server.enableCORS=True \ No newline at end of file