This commit is contained in:
Wael 2025-06-18 21:59:30 -03:00 committed by GitHub
commit 5aac9fb006
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 488 additions and 151 deletions

3
.gitignore vendored
View File

@ -25,4 +25,5 @@ node_modules
./models/*
venv/
.venv
.venv
/.vs

View File

@ -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)

View File

@ -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)}"
)

View File

@ -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)])

View File

@ -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

View File

@ -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

View File

@ -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