mirror of
https://github.com/harry0703/MoneyPrinterTurbo.git
synced 2026-02-21 08:27:22 +08:00
Merge bb9ee76b84 into 6cb5f23487
This commit is contained in:
commit
5aac9fb006
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,4 +25,5 @@ node_modules
|
||||
./models/*
|
||||
|
||||
venv/
|
||||
.venv
|
||||
.venv
|
||||
/.vs
|
||||
|
||||
@ -386,4 +386,4 @@ Click to view the [`LICENSE`](LICENSE) file
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)
|
||||
[](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date)
|
||||
@ -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)}"
|
||||
)
|
||||
|
||||
@ -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)])
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user