mirror of
https://github.com/linyqh/NarratoAI.git
synced 2025-12-12 19:52:48 +00:00
refactor(video): 可以剪辑短剧
- 添加多个视频处理相关函数,提高代码可复用性 - 优化日志输出,增加中文注释,提高代码可读性 -调整视频处理流程,提升效率和准确性 - 修复部分函数的参数类型和返回值类型
This commit is contained in:
parent
bd879079c3
commit
1a332c72bb
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user