refactor(video): 可以剪辑短剧

- 添加多个视频处理相关函数,提高代码可复用性
- 优化日志输出,增加中文注释,提高代码可读性
-调整视频处理流程,提升效率和准确性
- 修复部分函数的参数类型和返回值类型
This commit is contained in:
linyqh 2024-12-04 01:56:42 +08:00
parent bd879079c3
commit 1a332c72bb
4 changed files with 140 additions and 36 deletions

View File

@ -253,7 +253,7 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
segment for segment in list_script
if segment['OST'] in [0, 2]
]
logger.debug(f"tts_segments: {tts_segments}")
# logger.debug(f"tts_segments: {tts_segments}")
if tts_segments:
audio_files, sub_maker_list = voice.tts_multiple(
task_id=task_id,
@ -302,7 +302,7 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
logger.info("\n\n## 4. 裁剪视频")
subclip_videos = [x for x in subclip_path_videos.values()]
logger.debug(f"\n\n## 裁剪后的视频文件列表: \n{subclip_videos}")
# logger.debug(f"\n\n## 裁剪后的视频文件列表: \n{subclip_videos}")
if not subclip_videos:
sm.state.update_task(task_id, state=const.TASK_STATE_FAILED)

View File

@ -18,6 +18,15 @@ from app.utils import utils
def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""):
"""
获取背景音乐文件路径
Args:
bgm_type: 背景音乐类型可选值: random(随机), ""(无背景音乐)
bgm_file: 指定的背景音乐文件路径
Returns:
str: 背景音乐文件路径
"""
if not bgm_type:
return ""
@ -56,13 +65,27 @@ def combine_videos(
max_clip_duration: int = 5,
threads: int = 2,
) -> str:
"""
合并多个视频片段
Args:
combined_video_path: 合并后的视频保存路径
video_paths: 待合并的视频路径列表
audio_file: 音频文件路径
video_aspect: 视频宽高比
video_concat_mode: 视频拼接模式(随机/顺序)
max_clip_duration: 每个片段的最大时长()
threads: 处理线程数
Returns:
str: 合并后的视频路径
"""
audio_clip = AudioFileClip(audio_file)
audio_duration = audio_clip.duration
logger.info(f"max duration of audio: {audio_duration} seconds")
# Required duration of each clip
logger.info(f"音频时长: {audio_duration}")
# 每个片段的所需时长
req_dur = audio_duration / len(video_paths)
req_dur = max_clip_duration
logger.info(f"each clip will be maximum {req_dur} seconds long")
logger.info(f"每个片段最大时长: {req_dur}")
output_dir = os.path.dirname(combined_video_path)
aspect = VideoAspect(video_aspect)
@ -81,22 +104,22 @@ def combine_videos(
end_time = min(start_time + max_clip_duration, clip_duration)
split_clip = clip.subclip(start_time, end_time)
raw_clips.append(split_clip)
# logger.info(f"splitting from {start_time:.2f} to {end_time:.2f}, clip duration {clip_duration:.2f}, split_clip duration {split_clip.duration:.2f}")
# logger.info(f"从 {start_time:.2f} 到 {end_time:.2f}, 片段时长 {clip_duration:.2f}, 分割片段时长 {split_clip.duration:.2f}")
start_time = end_time
if video_concat_mode.value == VideoConcatMode.sequential.value:
break
# random video_paths order
# 随机视频片段顺序
if video_concat_mode.value == VideoConcatMode.random.value:
random.shuffle(raw_clips)
# Add downloaded clips over and over until the duration of the audio (max_duration) has been reached
# 添加下载的片段,直到音频时长(max_duration)达到
while video_duration < audio_duration:
for clip in raw_clips:
# Check if clip is longer than the remaining audio
# 检查片段是否比剩余音频时长长
if (audio_duration - video_duration) < clip.duration:
clip = clip.subclip(0, (audio_duration - video_duration))
# Only shorten clips if the calculated clip length (req_dur) is shorter than the actual clip to prevent still image
# 仅当计算的片段时长(req_dur)小于实际片段时长时,缩短片段
elif req_dur < clip.duration:
clip = clip.subclip(0, req_dur)
clip = clip.set_fps(30)
@ -134,7 +157,7 @@ def combine_videos(
)
logger.info(
f"resizing video to {video_width} x {video_height}, clip size: {clip_w} x {clip_h}"
f"调整视频尺寸为 {video_width} x {video_height}, 片段尺寸: {clip_w} x {clip_h}"
)
if clip.duration > max_clip_duration:
@ -146,7 +169,7 @@ def combine_videos(
video_clip = concatenate_videoclips(clips)
video_clip = video_clip.set_fps(30)
logger.info("writing")
# https://github.com/harry0703/NarratoAI/issues/111#issuecomment-2032354030
video_clip.write_videofile(
filename=combined_video_path,
threads=threads,
@ -161,6 +184,17 @@ def combine_videos(
def wrap_text(text, max_width, font, fontsize=60):
"""
文本自动换行处理
Args:
text: 待处理的文本
max_width: 最大宽度
font: 字体文件路径
fontsize: 字体大小
Returns:
tuple: (换行后的文本, 文本高度)
"""
# 创建字体对象
font = ImageFont.truetype(font, fontsize)
@ -220,6 +254,14 @@ def wrap_text(text, max_width, font, fontsize=60):
@contextmanager
def manage_clip(clip):
"""
视频片段资源管理器
Args:
clip: 视频片段对象
Yields:
VideoFileClip: 视频片段对象
"""
try:
yield clip
finally:
@ -232,6 +274,7 @@ def generate_video_v2(
audio_path: str,
subtitle_path: str,
output_file: str,
list_script: list,
params: Union[VideoParams, VideoClipParams],
progress_callback=None,
):
@ -335,6 +378,7 @@ def generate_video_v2(
update_progress("字幕处理完成")
# 合并音频和导出
logger.info("开始导出视频 (此步骤耗时较长请耐心等待)")
video_clip = video_clip.set_audio(final_audio)
video_clip.write_videofile(
output_file,
@ -356,7 +400,17 @@ def generate_video_v2(
def process_audio_tracks(original_audio, new_audio, params, video_duration):
"""处理所有音轨"""
"""
处理所有音轨(原声配音背景音乐)
Args:
original_audio: 原始音频
new_audio: 新音频
params: 视频参数
video_duration: 视频时长
Returns:
CompositeAudioClip: 合成后的音频
"""
audio_tracks = []
if original_audio is not None:
@ -379,7 +433,17 @@ def process_audio_tracks(original_audio, new_audio, params, video_duration):
def process_subtitles(subtitle_path, video_clip, video_duration, create_text_clip):
"""处理字幕"""
"""
处理字幕
Args:
subtitle_path: 字幕文件路径
video_clip: 视频片段
video_duration: 视频时长
create_text_clip: 创建文本片段的回调函数
Returns:
CompositeVideoClip: 添加字幕后的视频
"""
if not (subtitle_path and os.path.exists(subtitle_path)):
return video_clip
@ -403,6 +467,15 @@ def process_subtitles(subtitle_path, video_clip, video_duration, create_text_cli
def preprocess_video(materials: List[MaterialInfo], clip_duration=4):
"""
预处理视频素材
Args:
materials: 素材信息列表
clip_duration: 片段时长()
Returns:
List[MaterialInfo]: 处理后的素材信息列表
"""
for material in materials:
if not material.url:
continue
@ -430,12 +503,12 @@ def preprocess_video(materials: List[MaterialInfo], clip_duration=4):
# 使用resize方法来添加缩放效果。这里使用了lambda函数来使得缩放效果随时间变化。
# 假设我们想要从原始大小逐渐放大到120%的大小。
# t代表当前时间clip.duration为视频总时长这里是3秒。
# 注意1 表示100%的大小所以1.2表示120%的大小
# 注意1 表示100%的大小所以1.2表示120%的大小
zoom_clip = clip.resize(
lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration)
)
# 如果需要,可以创建一个包含缩放剪辑的复合频剪辑
# 如果需要,可以创建一个包含缩放剪辑的复合频剪辑
# (这在您想要在视频中添加其他元素时非常有用)
final_clip = CompositeVideoClip([zoom_clip])
@ -511,7 +584,7 @@ def combine_clip_videos(combined_video_path: str,
video_clip = concatenate_videoclips(clips)
video_clip = video_clip.set_fps(30)
logger.info("开始合并视频...")
logger.info("开始合并视频... (过程中出现 UserWarning: 不必理会)")
video_clip.write_videofile(
filename=combined_video_path,
threads=threads,
@ -521,7 +594,7 @@ def combine_clip_videos(combined_video_path: str,
temp_audiofile=os.path.join(output_dir, "temp-audio.m4a")
)
finally:
# 确保资源被正确<EFBFBD><EFBFBD><EFBFBD>
# 确保资源被正确
video_clip.close()
for clip in clips:
clip.close()
@ -531,7 +604,16 @@ def combine_clip_videos(combined_video_path: str,
def resize_video_with_padding(clip, target_width: int, target_height: int):
"""辅助函数:调整视频尺寸并添加黑边"""
"""
调整视频尺寸并添加黑边
Args:
clip: 视频片段
target_width: 目标宽度
target_height: 目标高度
Returns:
CompositeVideoClip: 调整尺寸后的视频
"""
clip_ratio = clip.w / clip.h
target_ratio = target_width / target_height
@ -559,7 +641,18 @@ def resize_video_with_padding(clip, target_width: int, target_height: int):
def validate_params(video_path, audio_path, output_file, params):
"""验证输入参数"""
"""
验证输入参数
Args:
video_path: 视频文件路径
audio_path: 音频文件路径
output_file: 输出文件路径
params: 视频参数
Raises:
FileNotFoundError: 文件不存在时抛出
ValueError: 参数无效时抛出
"""
if not os.path.exists(video_path):
raise FileNotFoundError(f"视频文件不存在: {video_path}")
@ -592,21 +685,21 @@ if __name__ == "__main__":
},
{
"timestamp": "00:45-01:01",
"picture": "好的以下是视频画面的客观描述:\n\n视频显示了一个人在森林里挖掘。\n\n第一个镜头是地面特写,显示出松的泥土、碎石和落叶。光线照在部分区域。\n\n第二个镜头中,一模糊不清的蹲一个树根旁挖掘,一个橄榄绿色的背包放在地上。树根缠绕着常春藤。\n\n第三个镜头显示该人在一个更开阔的区域挖掘,那里有一些树根,以及部分倒的树干。他起来像是在挖掘一个较大的坑。\n\n第四个镜头是特写镜头,显示该人用工具清理土坑的墙壁。\n\n第五个镜头是土坑内部的特写镜头,可以看到土质的纹理,有一些小树根和它植被的残留物。",
"narration": "现在,这位勇敢的挖掘者就像个“现代版的土豆农夫”,在林里开辟新天地。的目标是什么?挖一个宝藏还块“树根披萨”?小心哦,别让树根追着你喊:“不要挖我,我也是有故事的!”",
"picture": "好的以下是视频画面的客观描述:\n\n视频显示了一个人在森林里挖掘。\n\n第一个镜头是地面特写,显示出松<EFBFBD><EFBFBD>的泥土、碎石和落叶。光线照在部分区域。\n\n第二个镜头中,一模糊不清的蹲一个树根旁挖掘,一个橄榄绿色的背包放在地上。树根缠绕着常春藤。\n\n第三个镜头显示该人在一个更开阔的区域挖掘,那里有一些树根,以及部分倒的树干。他起来像是在挖掘一个较大的坑。\n\n第四个镜头是特写镜头,显示该人用工具清理土坑的墙壁。\n\n第五个镜头是土坑内部的特写镜头,可以看到土质的纹理,有一些小树根和它植被的残留物。",
"narration": "现在,这位勇敢的挖掘者就像个“现代版的土豆农夫”,在林里开辟新天地。的目标是什么?挖一个宝藏还块“树根披萨”?小心哦,别让树根追着你喊:“不要挖我,我也是有故事的!”",
"OST": 2,
"new_timestamp": "00:00:33,000-00:00:49,000"
},
{
"timestamp": "01:07-01:25",
"picture": "好,以下是视频画面的客观描述:\n\n画面1特写镜头显示出一丛带有水珠的深绿色灌木叶片。叶片呈椭圆形边缘光滑。背景是树根和泥土。\n\n画面2一个留着胡子的男人正在一个森林中土坑里挖掘。他穿着黑色T恤和卡其色裤子跪在地,用具挖掘泥土。周围环绕着树木、树根和灌木。一个倒下的树干横跨土坑上方。\n\n画面3同一个男人坐在他刚才挖的坑的边缘看着前方。他的表情似乎略带沉思。背景与画面2相同。\n\n画面4一个广角镜头显示出他挖出的坑。这是一个不规则形状的土坑在树木繁茂的斜坡上。土壤呈深棕色可见树根。\n\n画面5同一个男人跪在地上用一把小斧头砍一根木头。他穿着与前几个画面相同的衣服。地面上覆盖着落叶。周围是树木和灌木。",
"picture": "好,以下是视频画面的客观描述:\n\n画面1特写镜头显示出一丛带有水珠的深绿色灌木叶片。叶片呈椭圆形边缘光滑。背景是树根和泥土。\n\n画面2一个留着胡子的男人正在一个森林中土坑里挖掘。他穿着黑色T恤和卡其色裤子跪在地,用具挖掘泥土。周围环绕着树木、树根和灌木。一个倒下的树干横跨土坑上方。\n\n画面3同一个男人坐在他刚才挖的坑的边缘看着前方。他的表情似乎略带沉思。背景与画面2相同。\n\n画面4一个广角镜头显示出他挖出的坑。这是一个不规则形状的土坑在树木繁茂的斜坡上。土壤呈深棕色可见树根。\n\n画面5同一个男人跪在地上用一把小斧头砍一根木头。他穿着与前几个画面相同的衣服。地面上覆盖着落叶。周围是树木和灌木。",
"narration": "“哎呀,这片灌木叶子滴水如雨,感觉像是大自然的洗发水广告!但我这位‘挖宝达人’似乎更适合拍个‘森林里的单身狗’真人秀。等会儿,我要给树根唱首歌,听说它们爱音乐!”",
"OST": 2,
"new_timestamp": "00:00:49,000-00:01:07,000"
},
{
"timestamp": "01:36-01:53",
"picture": "好的,以下是视频画面内容的客观描述:\n\n视频包含三个镜头:\n\n**镜头一:**个小型、浅水池塘,位于树林中。池塘的水看起来浑浊,呈绿褐色。池塘周围遍布泥土和落叶。多根树枝和树干横跨池塘,部分浸没在水中。周围的植被茂密主要是深色树木和灌木。\n\n**镜头二:**距拍摄树深处,阳光透过树叶洒落在植被上。镜头中可见粗大的树干、树枝和各种绿叶植物。部分树枝似乎被砍断,切口可见。\n\n**镜头三:**近距离特写镜头,聚焦在树枝和绿叶上。叶片呈圆形,颜色为鲜绿色,有些叶片上有缺损。树枝颜色较深,呈现深褐色。背景是模糊的树林。\n",
"picture": "好的,以下是视频画面内容的客观描述:\n\n视频包含三个镜头:\n\n**镜头一:**个小型、浅水池塘,位于树林中。池塘的水看起来浑浊,呈绿褐色。池塘周围遍布泥土和落叶。多根树枝和树干横跨池塘,部分浸没在水中。周围的植被茂密主要是深色树木和灌木。\n\n**镜头二:**距拍摄树深处,阳光透过树叶洒落在植被上。镜头中可见粗大的树干、树枝和各种绿叶植物。部分树枝似乎被砍断,切口可见。\n\n**镜头三:**近距离特写镜头,聚焦在树枝和绿叶上。叶片呈圆形,颜色为鲜绿色,有些叶片上有缺损。树枝颜色较深,呈现深褐色。背景是模糊的树林。\n",
"narration": "“好吧,看来我们的‘挖宝达人’终于找到了一‘宝藏’——一个色泽如同绿豆汤的池塘!我敢打赌,这里不仅是小鱼儿的游乐场更是树枝们的‘水疗中心’!下次来这里,我得带上浮潜装备!”",
"OST": 2,
"new_timestamp": "00:01:07,000-00:01:24,000"
@ -639,8 +732,9 @@ if __name__ == "__main__":
output_file = "../../storage/tasks/123/final-123.mp4"
generate_video_v2(video_path=video_path,
audio_path=audio_path,
subtitle_path=subtitle_path,
output_file=output_file,
params=cfg
audio_path=audio_path,
subtitle_path=subtitle_path,
output_file=output_file,
params=cfg,
list_script=list_script,
)

View File

@ -40,7 +40,7 @@ def to_json(obj):
# 如果对象是二进制数据转换为base64编码的字符串
elif isinstance(o, bytes):
return "*** binary data ***"
# 如果<EFBFBD><EFBFBD><EFBFBD>象是字典,递归处理每个键值对
# 如果象是字典,递归处理每个键值对
elif isinstance(o, dict):
return {k: serialize(v) for k, v in o.items()}
# 如果对象是列表或元组,递归处理每个元素
@ -56,7 +56,7 @@ def to_json(obj):
# 使用serialize函数处理输入对象
serialized_obj = serialize(obj)
# 序列化处理后的对象为JSON<EFBFBD><EFBFBD><EFBFBD>符串
# 序列化处理后的对象为JSON符串
return json.dumps(serialized_obj, ensure_ascii=False, indent=4)
except Exception as e:
return None
@ -354,15 +354,25 @@ def seconds_to_time(seconds: float) -> str:
def calculate_total_duration(scenes):
"""
计算场景列表的总时长
Args:
scenes: 场景列表每个场景包含 timestamp 字段格式如 "00:00:28,350-00:00:41,000"
Returns:
float: 总时长
"""
total_seconds = 0
for scene in scenes:
start, end = scene['timestamp'].split('-')
start_time = datetime.strptime(start, '%M:%S')
end_time = datetime.strptime(end, '%M:%S')
# 使用 time_to_seconds 函数处理更精确的时间格式
start_seconds = time_to_seconds(start)
end_seconds = time_to_seconds(end)
duration = end_time - start_time
total_seconds += duration.total_seconds()
duration = end_seconds - start_seconds
total_seconds += duration
return total_seconds
@ -485,7 +495,7 @@ def clear_keyframes_cache(video_path: str = None):
return
if video_path:
# <EFBFBD><EFBFBD><EFBFBD>理指定视频的缓存
# 理指定视频的缓存
video_hash = md5(video_path + str(os.path.getmtime(video_path)))
video_keyframes_dir = os.path.join(keyframes_dir, video_hash)
if os.path.exists(video_keyframes_dir):

View File

@ -26,7 +26,7 @@ psutil>=5.9.0
opencv-python~=4.10.0.84
scikit-learn~=1.5.2
google-generativeai~=0.8.3
pillow~=10.3.0
pillow==10.3.0
python-dotenv~=1.0.1
openai~=1.53.0
tqdm>=4.66.6