diff --git a/app/models/schema.py b/app/models/schema.py index f10daef..623a3fd 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -368,7 +368,7 @@ class VideoClipParams(BaseModel): tts_volume: Optional[float] = Field(default=1.0, description="解说语音音量(后处理)") original_volume: Optional[float] = Field(default=1.0, description="视频原声音量") - bgm_volume: Optional[float] = Field(default=0.6, description="背景音乐音量") + bgm_volume: Optional[float] = Field(default=0.3, description="背景音乐音量") class VideoTranscriptionRequest(BaseModel): diff --git a/app/services/clip_video.py b/app/services/clip_video.py index c10faf4..1329333 100644 --- a/app/services/clip_video.py +++ b/app/services/clip_video.py @@ -22,10 +22,10 @@ def parse_timestamp(timestamp: str) -> tuple: 解析时间戳字符串,返回开始和结束时间 Args: - timestamp: 格式为'HH:MM:SS-HH:MM:SS'的时间戳字符串 + timestamp: 格式为'HH:MM:SS-HH:MM:SS'或'HH:MM:SS,sss-HH:MM:SS,sss'的时间戳字符串 Returns: - tuple: (开始时间, 结束时间) 格式为'HH:MM:SS' + tuple: (开始时间, 结束时间) 格式为'HH:MM:SS'或'HH:MM:SS,sss' """ start_time, end_time = timestamp.split('-') return start_time, end_time @@ -36,21 +36,40 @@ def calculate_end_time(start_time: str, duration: float, extra_seconds: float = 根据开始时间和持续时间计算结束时间 Args: - start_time: 开始时间,格式为'HH:MM:SS' + start_time: 开始时间,格式为'HH:MM:SS'或'HH:MM:SS,sss'(带毫秒) duration: 持续时间,单位为秒 extra_seconds: 额外添加的秒数,默认为1秒 Returns: - str: 计算后的结束时间,格式为'HH:MM:SS' + str: 计算后的结束时间,格式与输入格式相同 """ - h, m, s = map(int, start_time.split(':')) - total_seconds = h * 3600 + m * 60 + s + duration + extra_seconds - + # 检查是否包含毫秒 + has_milliseconds = ',' in start_time + milliseconds = 0 + + if has_milliseconds: + time_part, ms_part = start_time.split(',') + h, m, s = map(int, time_part.split(':')) + milliseconds = int(ms_part) + else: + h, m, s = map(int, start_time.split(':')) + + # 转换为总毫秒数 + total_milliseconds = ((h * 3600 + m * 60 + s) * 1000 + milliseconds + + int((duration + extra_seconds) * 1000)) + + # 计算新的时、分、秒、毫秒 + ms_new = total_milliseconds % 1000 + total_seconds = total_milliseconds // 1000 h_new = int(total_seconds // 3600) m_new = int((total_seconds % 3600) // 60) s_new = int(total_seconds % 60) - - return f"{h_new:02d}:{m_new:02d}:{s_new:02d}" + + # 返回与输入格式一致的时间字符串 + if has_milliseconds: + return f"{h_new:02d}:{m_new:02d}:{s_new:02d},{ms_new:03d}" + else: + return f"{h_new:02d}:{m_new:02d}:{s_new:02d}" def check_hardware_acceleration() -> Optional[str]: @@ -144,24 +163,30 @@ def clip_video( result = {} for item in tts_result: - _id = item["_id"] + _id = item.get("_id", item.get("timestamp", "unknown")) timestamp = item["timestamp"] start_time, _ = parse_timestamp(timestamp) # 根据持续时间计算真正的结束时间(加上1秒余量) duration = item["duration"] calculated_end_time = calculate_end_time(start_time, duration) + + # 转换为FFmpeg兼容的时间格式(逗号替换为点) + ffmpeg_start_time = start_time.replace(',', '.') + ffmpeg_end_time = calculated_end_time.replace(',', '.') - # 格式化输出文件名 - output_filename = f"vid-{start_time.replace(':', '-')}-{calculated_end_time.replace(':', '-')}.mp4" + # 格式化输出文件名(使用连字符替代冒号和逗号) + safe_start_time = start_time.replace(':', '-').replace(',', '-') + safe_end_time = calculated_end_time.replace(':', '-').replace(',', '-') + output_filename = f"vid_{safe_start_time}@{safe_end_time}.mp4" output_path = os.path.join(output_dir, output_filename) # 构建FFmpeg命令 ffmpeg_cmd = [ "ffmpeg", "-y", *hwaccel_args, "-i", video_origin_path, - "-ss", start_time, - "-to", calculated_end_time, + "-ss", ffmpeg_start_time, + "-to", ffmpeg_end_time, "-c:v", "h264_videotoolbox" if hwaccel == "videotoolbox" else "libx264", "-c:a", "aac", "-strict", "experimental", @@ -170,7 +195,7 @@ def clip_video( # 执行FFmpeg命令 try: - logger.info(f"裁剪视频片段: {timestamp} -> {start_time}到{calculated_end_time}") + logger.info(f"裁剪视频片段: {timestamp} -> {ffmpeg_start_time}到{ffmpeg_end_time}") # logger.debug(f"执行命令: {' '.join(ffmpeg_cmd)}") process = subprocess.run( diff --git a/app/services/task.py b/app/services/task.py index 018d149..cfaf66c 100644 --- a/app/services/task.py +++ b/app/services/task.py @@ -377,20 +377,18 @@ def validate_params(video_path, audio_path, output_file, params): if __name__ == "__main__": - task_id = "qyn2-2-demo" + task_id = "demo" # 提前裁剪是为了方便检查视频 subclip_path_videos = { - 1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-00-00-00-01-15.mp4', - 2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-01-15-00-04-40.mp4', - 3: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-41-00-04-58.mp4', - 4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-58-00-05-45.mp4', - 5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-05-45-00-06-00.mp4', - 6: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-06-00-00-06-03.mp4', + 1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-00-00_000-00-00-27_000.mp4', + 2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-00-30_000-00-00-57_000.mp4', + 3: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-01-00_000-00-01-27_000.mp4', + 4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-01-30_000-00-01-57_000.mp4', } params = VideoClipParams( - video_clip_json_path="/Users/apple/Desktop/home/NarratoAI/resource/scripts/demo.json", - video_origin_path="/Users/apple/Desktop/home/NarratoAI/resource/videos/qyn2-2无片头片尾.mp4", + video_clip_json_path="/Users/apple/Desktop/home/NarratoAI/resource/scripts/2025-0507-185159.json", + video_origin_path="/Users/apple/Desktop/home/NarratoAI/resource/videos/test.mp4", ) start_subclip(task_id, params, subclip_path_videos) diff --git a/app/services/update_script.py b/app/services/update_script.py index 43f2ff8..2eb9663 100644 --- a/app/services/update_script.py +++ b/app/services/update_script.py @@ -21,16 +21,25 @@ def extract_timestamp_from_video_path(video_path: str) -> str: video_path: 视频文件路径 Returns: - 提取出的时间戳,格式为 'HH:MM:SS-HH:MM:SS' + 提取出的时间戳,格式为 'HH:MM:SS-HH:MM:SS' 或 'HH:MM:SS,sss-HH:MM:SS,sss' """ # 使用正则表达式从文件名中提取时间戳 filename = os.path.basename(video_path) - match = re.search(r'vid-(\d{2}-\d{2}-\d{2})-(\d{2}-\d{2}-\d{2})\.mp4', filename) - - if match: + + # 匹配新格式: vid_00-00-00-000@00-00-20-250.mp4 + match_new = re.search(r'vid_(\d{2})-(\d{2})-(\d{2})-(\d{3})@(\d{2})-(\d{2})-(\d{2})-(\d{3})\.mp4', filename) + if match_new: + # 提取并格式化时间戳(包含毫秒) + start_h, start_m, start_s, start_ms = match_new.group(1), match_new.group(2), match_new.group(3), match_new.group(4) + end_h, end_m, end_s, end_ms = match_new.group(5), match_new.group(6), match_new.group(7), match_new.group(8) + return f"{start_h}:{start_m}:{start_s},{start_ms}-{end_h}:{end_m}:{end_s},{end_ms}" + + # 匹配旧格式: vid-00-00-00-00-00-00.mp4 + match_old = re.search(r'vid-(\d{2}-\d{2}-\d{2})-(\d{2}-\d{2}-\d{2})\.mp4', filename) + if match_old: # 提取并格式化时间戳 - start_time = match.group(1).replace('-', ':') - end_time = match.group(2).replace('-', ':') + start_time = match_old.group(1).replace('-', ':') + end_time = match_old.group(2).replace('-', ':') return f"{start_time}-{end_time}" return "" @@ -41,7 +50,7 @@ def calculate_duration(timestamp: str) -> float: 计算时间戳范围的持续时间(秒) Args: - timestamp: 格式为 'HH:MM:SS-HH:MM:SS' 的时间戳 + timestamp: 格式为 'HH:MM:SS-HH:MM:SS' 或 'HH:MM:SS,sss-HH:MM:SS,sss' 的时间戳 Returns: 持续时间(秒) @@ -49,13 +58,28 @@ def calculate_duration(timestamp: str) -> float: try: start_time, end_time = timestamp.split('-') - # 解析时间 - start_h, start_m, start_s = map(int, start_time.split(':')) - end_h, end_m, end_s = map(int, end_time.split(':')) + # 处理毫秒部分 + if ',' in start_time: + start_parts = start_time.split(',') + start_time_parts = start_parts[0].split(':') + start_ms = float('0.' + start_parts[1]) if len(start_parts) > 1 else 0 + start_h, start_m, start_s = map(int, start_time_parts) + else: + start_h, start_m, start_s = map(int, start_time.split(':')) + start_ms = 0 + + if ',' in end_time: + end_parts = end_time.split(',') + end_time_parts = end_parts[0].split(':') + end_ms = float('0.' + end_parts[1]) if len(end_parts) > 1 else 0 + end_h, end_m, end_s = map(int, end_time_parts) + else: + end_h, end_m, end_s = map(int, end_time.split(':')) + end_ms = 0 # 转换为秒 - start_seconds = start_h * 3600 + start_m * 60 + start_s - end_seconds = end_h * 3600 + end_m * 60 + end_s + start_seconds = start_h * 3600 + start_m * 60 + start_s + start_ms + end_seconds = end_h * 3600 + end_m * 60 + end_s + end_ms # 计算时间差(秒) return round(end_seconds - start_seconds, 2) @@ -177,51 +201,51 @@ if __name__ == '__main__': list_script = [ { 'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!', - 'timestamp': '00:00:00-00:01:15', + 'timestamp': '00:00:00,001-00:01:15,001', 'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!', 'OST': 0, '_id': 1 }, { 'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!', - 'timestamp': '00:01:15-00:04:40', + 'timestamp': '00:01:15,001-00:04:40,001', 'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…', 'OST': 0, '_id': 2 }, { 'picture': '画面切到王启年小心翼翼地向范闲汇报。', - 'timestamp': '00:04:41-00:04:58', + 'timestamp': '00:04:41,001-00:04:58,001', 'narration': '我发现大人的死讯不光是在民间,在官场上也它传开了,所以呢,所以啊,可不是什么好事,将来您跟陛下怎么交代,这可是欺君之罪', 'OST': 1, '_id': 3 }, { 'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。', - 'timestamp': '00:04:58-00:05:45', + 'timestamp': '00:04:58,001-00:05:45,001', 'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!', 'OST': 0, '_id': 4 }, { 'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!', - 'timestamp': '00:05:45-00:06:00', + 'timestamp': '00:05:45,001-00:06:00,001', 'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!', 'OST': 0, '_id': 5 }, { 'picture': '画面切换到范闲蒙面闯入皇宫,被侍卫包围的场景。', - 'timestamp': '00:06:00-00:06:03', + 'timestamp': '00:06:00,001-00:06:03,001', 'narration': '抓刺客', 'OST': 1, '_id': 6 }] video_res = { - 1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-00-00-00-00-26.mp4', - 2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-01-15-00-01-29.mp4', - 4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-04-58-00-05-20.mp4', - 5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-05-45-00-05-53.mp4'} + 1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-00-00-000@00-00-20-250.mp4', + 2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-00-30-000@00-00-48-950.mp4', + 4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-01-00-000@00-01-15-688.mp4', + 5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-01-30-000@00-01-49-512.mp4'} audio_res = { 1: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3', 2: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3', diff --git a/requirements.txt b/requirements.txt index 207865b..ffc3dc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ streamlit~=1.45.0 watchdog==6.0.0 loguru~=0.7.3 tomli~=2.2.1 +pydub==0.25.1 openai~=1.77.0 google-generativeai>=0.8.5 diff --git a/webui.py b/webui.py index 5f296cd..73f64f0 100644 --- a/webui.py +++ b/webui.py @@ -120,77 +120,75 @@ def tr(key): def render_generate_button(): """渲染生成按钮和处理逻辑""" if st.button(tr("Generate Video"), use_container_width=True, type="primary"): + from app.services import task as tm + + # 重置日志容器和记录 + log_container = st.empty() + log_records = [] + + def log_received(msg): + with log_container: + log_records.append(msg) + st.code("\n".join(log_records)) + + from loguru import logger + logger.add(log_received) + + config.save_config() + task_id = st.session_state.get('task_id') + + if not task_id: + st.error(tr("请先裁剪视频")) + return + if not st.session_state.get('video_clip_json_path'): + st.error(tr("脚本文件不能为空")) + return + if not st.session_state.get('video_origin_path'): + st.error(tr("视频文件不能为空")) + return + + st.toast(tr("生成视频")) + logger.info(tr("开始生成视频")) + + # 获取所有参数 + script_params = script_settings.get_script_params() + video_params = video_settings.get_video_params() + audio_params = audio_settings.get_audio_params() + subtitle_params = subtitle_settings.get_subtitle_params() + + # 合并所有参数 + all_params = { + **script_params, + **video_params, + **audio_params, + **subtitle_params + } + + # 创建参数对象 + params = VideoClipParams(**all_params) + + result = tm.start_subclip( + task_id=task_id, + params=params, + subclip_path_videos=st.session_state['subclip_videos'] + ) + + video_files = result.get("videos", []) + st.success(tr("视生成完成")) + try: - from app.services import task as tm - import torch + if video_files: + player_cols = st.columns(len(video_files) * 2 + 1) + for i, url in enumerate(video_files): + player_cols[i * 2 + 1].video(url) + except Exception as e: + logger.error(f"播放视频失败: {e}") - # 重置日志容器和记录 - log_container = st.empty() - log_records = [] + file_utils.open_task_folder(config.root_dir, task_id) + logger.info(tr("视频生成完成")) - def log_received(msg): - with log_container: - log_records.append(msg) - st.code("\n".join(log_records)) - - from loguru import logger - logger.add(log_received) - - config.save_config() - task_id = st.session_state.get('task_id') - - if not task_id: - st.error(tr("请先裁剪视频")) - return - if not st.session_state.get('video_clip_json_path'): - st.error(tr("脚本文件不能为空")) - return - if not st.session_state.get('video_origin_path'): - st.error(tr("视频文件不能为空")) - return - - st.toast(tr("生成视频")) - logger.info(tr("开始生成视频")) - - # 获取所有参数 - script_params = script_settings.get_script_params() - video_params = video_settings.get_video_params() - audio_params = audio_settings.get_audio_params() - subtitle_params = subtitle_settings.get_subtitle_params() - - # 合并所有参数 - all_params = { - **script_params, - **video_params, - **audio_params, - **subtitle_params - } - - # 创建参数对象 - params = VideoClipParams(**all_params) - - result = tm.start_subclip( - task_id=task_id, - params=params, - subclip_path_videos=st.session_state['subclip_videos'] - ) - - video_files = result.get("videos", []) - st.success(tr("视生成完成")) - - try: - if video_files: - player_cols = st.columns(len(video_files) * 2 + 1) - for i, url in enumerate(video_files): - player_cols[i * 2 + 1].video(url) - except Exception as e: - logger.error(f"播放视频失败: {e}") - - file_utils.open_task_folder(config.root_dir, task_id) - logger.info(tr("视频生成完成")) - - finally: - PerformanceMonitor.cleanup_resources() + # finally: + # PerformanceMonitor.cleanup_resources() def main():