diff --git a/.gitignore b/.gitignore
index 4bea0a5..8096610 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,4 +32,5 @@ resource/fonts/*.ttf
resource/fonts/*.otf
resource/srt/*.srt
app/models/faster-whisper-large-v2/*
+app/models/faster-whisper-large-v3/*
app/models/bert/*
diff --git a/README-cn.md b/README-en.md
similarity index 100%
rename from README-cn.md
rename to README-en.md
diff --git a/README.md b/README.md
index b969811..7528267 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[//]: # (

)
@@ -83,7 +83,7 @@ _**注意⚠️:近期在 x (推特) 上发现有人冒充作者在 pump.fun
## 配置要求 📦
- 建议最低 CPU 4核或以上,内存 8G 或以上,显卡非必须
-- Windows 10 或 MacOS 11.0 以上系统
+- Windows 10/11 或 MacOS 11.0 以上系统
- [Python 3.10+](https://www.python.org/downloads/)
## 反馈建议 📢
diff --git a/app/models/schema.py b/app/models/schema.py
index 5e2e909..ddf0ad1 100644
--- a/app/models/schema.py
+++ b/app/models/schema.py
@@ -20,7 +20,9 @@ class VideoConcatMode(str, Enum):
class VideoAspect(str, Enum):
landscape = "16:9"
+ landscape_2 = "4:3"
portrait = "9:16"
+ portrait_2 = "3:4"
square = "1:1"
def to_resolution(self):
@@ -360,13 +362,14 @@ class VideoClipParams(BaseModel):
text_back_color: Optional[str] = None # 文本背景色
stroke_color: str = "black" # 描边颜色
stroke_width: float = 1.5 # 描边宽度
- subtitle_position: str = "bottom" # top, bottom, center, custom
+ subtitle_position: str = "bottom" # top, bottom, center, custom
+ custom_position: float = 70.0 # 自定义位置
- n_threads: Optional[int] = Field(default=16, description="解说语音音量") # 线程���,有助于提升视频处理速度
+ n_threads: Optional[int] = Field(default=16, description="线程数") # 线程数,有助于提升视频处理速度
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/models/schema_v2.py b/app/models/schema_v2.py
index 1611a3b..8584c75 100644
--- a/app/models/schema_v2.py
+++ b/app/models/schema_v2.py
@@ -6,6 +6,7 @@ class GenerateScriptRequest(BaseModel):
video_path: str
video_theme: Optional[str] = ""
custom_prompt: Optional[str] = ""
+ frame_interval_input: Optional[int] = 5
skip_seconds: Optional[int] = 0
threshold: Optional[int] = 30
vision_batch_size: Optional[int] = 5
diff --git a/app/services/SDP/generate_script_short.pyd b/app/services/SDP/generate_script_short.pyd
index 72c29a7..de8b47c 100644
Binary files a/app/services/SDP/generate_script_short.pyd and b/app/services/SDP/generate_script_short.pyd differ
diff --git a/app/services/SDP/generate_script_short.so b/app/services/SDP/generate_script_short.so
index fb65efd..d659cd4 100755
Binary files a/app/services/SDP/generate_script_short.so and b/app/services/SDP/generate_script_short.so differ
diff --git a/app/services/SDP/utils/short_schema.pyd b/app/services/SDP/utils/short_schema.pyd
index e6b7c24..7774303 100644
Binary files a/app/services/SDP/utils/short_schema.pyd and b/app/services/SDP/utils/short_schema.pyd differ
diff --git a/app/services/SDP/utils/short_schema.so b/app/services/SDP/utils/short_schema.so
index 161acc5..933ef73 100755
Binary files a/app/services/SDP/utils/short_schema.so and b/app/services/SDP/utils/short_schema.so differ
diff --git a/app/services/SDP/utils/step1_subtitle_analyzer_openai.pyd b/app/services/SDP/utils/step1_subtitle_analyzer_openai.pyd
index 798a5c8..5c1da0b 100644
Binary files a/app/services/SDP/utils/step1_subtitle_analyzer_openai.pyd and b/app/services/SDP/utils/step1_subtitle_analyzer_openai.pyd differ
diff --git a/app/services/SDP/utils/step1_subtitle_analyzer_openai.so b/app/services/SDP/utils/step1_subtitle_analyzer_openai.so
index 94a963e..f43f7d4 100755
Binary files a/app/services/SDP/utils/step1_subtitle_analyzer_openai.so and b/app/services/SDP/utils/step1_subtitle_analyzer_openai.so differ
diff --git a/app/services/SDP/utils/step2_subtitle_analyzer_bert.pyd b/app/services/SDP/utils/step2_subtitle_analyzer_bert.pyd
index 1cfa6ea..1e6913d 100644
Binary files a/app/services/SDP/utils/step2_subtitle_analyzer_bert.pyd and b/app/services/SDP/utils/step2_subtitle_analyzer_bert.pyd differ
diff --git a/app/services/SDP/utils/step2_subtitle_analyzer_bert.so b/app/services/SDP/utils/step2_subtitle_analyzer_bert.so
index 2e67bb1..8b6587d 100755
Binary files a/app/services/SDP/utils/step2_subtitle_analyzer_bert.so and b/app/services/SDP/utils/step2_subtitle_analyzer_bert.so differ
diff --git a/app/services/SDP/utils/step3_fragment_check.pyd b/app/services/SDP/utils/step3_fragment_check.pyd
index bbc015d..38f4991 100644
Binary files a/app/services/SDP/utils/step3_fragment_check.pyd and b/app/services/SDP/utils/step3_fragment_check.pyd differ
diff --git a/app/services/SDP/utils/step3_fragment_check.so b/app/services/SDP/utils/step3_fragment_check.so
index 2bd0ff3..e57e026 100755
Binary files a/app/services/SDP/utils/step3_fragment_check.so and b/app/services/SDP/utils/step3_fragment_check.so differ
diff --git a/app/services/SDP/utils/step4_text_generate.pyd b/app/services/SDP/utils/step4_text_generate.pyd
index 77cef03..2454e49 100644
Binary files a/app/services/SDP/utils/step4_text_generate.pyd and b/app/services/SDP/utils/step4_text_generate.pyd differ
diff --git a/app/services/SDP/utils/step4_text_generate.so b/app/services/SDP/utils/step4_text_generate.so
index 916415f..fd536e7 100755
Binary files a/app/services/SDP/utils/step4_text_generate.so and b/app/services/SDP/utils/step4_text_generate.so differ
diff --git a/app/services/SDP/utils/step5_merge_script.pyd b/app/services/SDP/utils/step5_merge_script.pyd
index 4ceaf8b..b284950 100644
Binary files a/app/services/SDP/utils/step5_merge_script.pyd and b/app/services/SDP/utils/step5_merge_script.pyd differ
diff --git a/app/services/SDP/utils/step5_merge_script.so b/app/services/SDP/utils/step5_merge_script.so
index 11e685a..ec8181e 100755
Binary files a/app/services/SDP/utils/step5_merge_script.so and b/app/services/SDP/utils/step5_merge_script.so differ
diff --git a/app/services/SDP/utils/utils.pyd b/app/services/SDP/utils/utils.pyd
index ad16a2c..8c70c3d 100644
Binary files a/app/services/SDP/utils/utils.pyd and b/app/services/SDP/utils/utils.pyd differ
diff --git a/app/services/SDP/utils/utils.so b/app/services/SDP/utils/utils.so
index 8d48207..608acd4 100755
Binary files a/app/services/SDP/utils/utils.so and b/app/services/SDP/utils/utils.so differ
diff --git a/app/services/audio_merger.py b/app/services/audio_merger.py
index c7edc77..bedb585 100644
--- a/app/services/audio_merger.py
+++ b/app/services/audio_merger.py
@@ -18,15 +18,14 @@ def check_ffmpeg():
return False
-def merge_audio_files(task_id: str, audio_files: list, total_duration: float, list_script: list):
+def merge_audio_files(task_id: str, total_duration: float, list_script: list):
"""
- 合并音频文件,根据OST设置处理不同的音频轨道
+ 合并音频文件
Args:
task_id: 任务ID
- audio_files: TTS生成的音频文件列表
total_duration: 总时长
- list_script: 完整脚本信息,包含OST设置
+ list_script: 完整脚本信息,包含duration时长和audio路径
Returns:
str: 合并后的音频文件路径
@@ -39,36 +38,38 @@ def merge_audio_files(task_id: str, audio_files: list, total_duration: float, li
# 创建一个空的音频片段
final_audio = AudioSegment.silent(duration=total_duration * 1000) # 总时长以毫秒为单位
+ # 计算每个片段的开始位置(基于duration字段)
+ current_position = 0 # 初始位置(秒)
+
# 遍历脚本中的每个片段
- for segment, audio_file in zip(list_script, audio_files):
+ for segment in list_script:
try:
- # 加载TTS音频文件
- tts_audio = AudioSegment.from_file(audio_file)
-
- # 获取片段的开始和结束时间
- start_time, end_time = segment['new_timestamp'].split('-')
- start_seconds = utils.time_to_seconds(start_time)
- end_seconds = utils.time_to_seconds(end_time)
-
- # 根据OST设置处理音频
- if segment['OST'] == 0:
- # 只使用TTS音频
- final_audio = final_audio.overlay(tts_audio, position=start_seconds * 1000)
- elif segment['OST'] == 1:
- # 只使用原声(假设原声已经在视频中)
- continue
- elif segment['OST'] == 2:
- # 混合TTS音频和原声
- original_audio = AudioSegment.silent(duration=(end_seconds - start_seconds) * 1000)
- mixed_audio = original_audio.overlay(tts_audio)
- final_audio = final_audio.overlay(mixed_audio, position=start_seconds * 1000)
+ # 获取片段时长(秒)
+ duration = segment['duration']
+
+ # 检查audio字段是否为空
+ if segment['audio'] and os.path.exists(segment['audio']):
+ # 加载TTS音频文件
+ tts_audio = AudioSegment.from_file(segment['audio'])
+
+ # 将TTS音频添加到最终音频
+ final_audio = final_audio.overlay(tts_audio, position=current_position * 1000)
+ else:
+ # audio为空,不添加音频,仅保留间隔
+ logger.info(f"片段 {segment.get('timestamp', '')} 没有音频文件,保留 {duration} 秒的间隔")
+
+ # 更新下一个片段的开始位置
+ current_position += duration
except Exception as e:
- logger.error(f"处理音频文件 {audio_file} 时出错: {str(e)}")
+ logger.error(f"处理音频片段时出错: {str(e)}")
+ # 即使处理失败,也要更新位置,确保后续片段位置正确
+ if 'duration' in segment:
+ current_position += segment['duration']
continue
# 保存合并后的音频文件
- output_audio_path = os.path.join(utils.task_dir(task_id), "final_audio.mp3")
+ output_audio_path = os.path.join(utils.task_dir(task_id), "merger_audio.mp3")
final_audio.export(output_audio_path, format="mp3")
logger.info(f"合并后的音频文件已保存: {output_audio_path}")
@@ -93,7 +94,7 @@ def time_to_seconds(time_str):
# 分割时间部分
parts = time_part.split(':')
-
+
if len(parts) == 3: # HH:MM:SS
h, m, s = map(int, parts)
seconds = h * 3600 + m * 60 + s
@@ -118,11 +119,11 @@ def extract_timestamp(filename):
# 从文件名中提取时间部分
time_part = filename.split('_', 1)[1].split('.')[0] # 获取 "00_06,500-00_24,800" 部分
start_time, end_time = time_part.split('-') # 分割成开始和结束时间
-
+
# 将下划线格式转换回冒号格式
start_time = start_time.replace('_', ':')
end_time = end_time.replace('_', ':')
-
+
# 将时间戳转换为秒
start_seconds = time_to_seconds(start_time)
end_seconds = time_to_seconds(end_time)
@@ -135,17 +136,36 @@ def extract_timestamp(filename):
if __name__ == "__main__":
# 示例用法
- audio_files =[
- "/Users/apple/Desktop/home/NarratoAI/storage/tasks/test456/audio_00:06-00:24.mp3",
- "/Users/apple/Desktop/home/NarratoAI/storage/tasks/test456/audio_00:32-00:38.mp3",
- "/Users/apple/Desktop/home/NarratoAI/storage/tasks/test456/audio_00:43-00:52.mp3",
- "/Users/apple/Desktop/home/NarratoAI/storage/tasks/test456/audio_00:52-01:09.mp3",
- "/Users/apple/Desktop/home/NarratoAI/storage/tasks/test456/audio_01:13-01:15.mp3",
- ]
- total_duration = 38
- video_script_path = "/Users/apple/Desktop/home/NarratoAI/resource/scripts/test003.json"
- with open(video_script_path, "r", encoding="utf-8") as f:
- video_script = json.load(f)
+ total_duration = 90
- output_file = merge_audio_files("test456", audio_files, total_duration, video_script)
+ video_script = [
+ {'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!',
+ 'timestamp': '00:00:00-00:00:26',
+ 'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!',
+ 'OST': 0, 'duration': 26,
+ 'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3'},
+ {'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!', 'timestamp': '00:01:15-00:01:29',
+ 'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…',
+ 'OST': 0, 'duration': 14,
+ 'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3'},
+ {'picture': '画面切到王启年小心翼翼地向范闲汇报。', 'timestamp': '00:04:41-00:04:58',
+ 'narration': '我发现大人的死讯不光是在民间,在官场上也它传开了,所以呢,所以啊,可不是什么好事,将来您跟陛下怎么交代,这可是欺君之罪',
+ 'OST': 1, 'duration': 17,
+ 'audio': ''},
+ {'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。',
+ 'timestamp': '00:04:58-00:05:20',
+ 'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!',
+ 'OST': 0, 'duration': 22,
+ 'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_04_58-00_05_45.mp3'},
+ {'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
+ 'timestamp': '00:05:45-00:05:53',
+ 'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
+ 'OST': 0, 'duration': 8,
+ 'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_05_45-00_06_00.mp3'},
+ {'picture': '画面切换到范闲蒙面闯入皇宫,被侍卫包围的场景。', 'timestamp': '00:06:00-00:06:03',
+ 'narration': '抓刺客',
+ 'OST': 1, 'duration': 3,
+ 'audio': ''}]
+
+ output_file = merge_audio_files("test456", total_duration, video_script)
print(output_file)
diff --git a/app/services/clip_video.py b/app/services/clip_video.py
new file mode 100644
index 0000000..1329333
--- /dev/null
+++ b/app/services/clip_video.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+'''
+@Project: NarratoAI
+@File : clip_video
+@Author : 小林同学
+@Date : 2025/5/6 下午6:14
+'''
+
+import os
+import subprocess
+import json
+import hashlib
+from loguru import logger
+from typing import Dict, List, Optional
+from pathlib import Path
+
+
+def parse_timestamp(timestamp: str) -> tuple:
+ """
+ 解析时间戳字符串,返回开始和结束时间
+
+ Args:
+ timestamp: 格式为'HH:MM:SS-HH:MM:SS'或'HH:MM:SS,sss-HH:MM:SS,sss'的时间戳字符串
+
+ Returns:
+ tuple: (开始时间, 结束时间) 格式为'HH:MM:SS'或'HH:MM:SS,sss'
+ """
+ start_time, end_time = timestamp.split('-')
+ return start_time, end_time
+
+
+def calculate_end_time(start_time: str, duration: float, extra_seconds: float = 1.0) -> str:
+ """
+ 根据开始时间和持续时间计算结束时间
+
+ Args:
+ start_time: 开始时间,格式为'HH:MM:SS'或'HH:MM:SS,sss'(带毫秒)
+ duration: 持续时间,单位为秒
+ extra_seconds: 额外添加的秒数,默认为1秒
+
+ Returns:
+ str: 计算后的结束时间,格式与输入格式相同
+ """
+ # 检查是否包含毫秒
+ 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)
+
+ # 返回与输入格式一致的时间字符串
+ 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]:
+ """
+ 检查系统支持的硬件加速选项
+
+ Returns:
+ Optional[str]: 硬件加速参数,如果不支持则返回None
+ """
+ # 检查NVIDIA GPU支持
+ try:
+ nvidia_check = subprocess.run(
+ ["ffmpeg", "-hwaccel", "cuda", "-i", "/dev/null", "-f", "null", "-"],
+ stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, check=False
+ )
+ if nvidia_check.returncode == 0:
+ return "cuda"
+ except Exception:
+ pass
+
+ # 检查MacOS videotoolbox支持
+ try:
+ videotoolbox_check = subprocess.run(
+ ["ffmpeg", "-hwaccel", "videotoolbox", "-i", "/dev/null", "-f", "null", "-"],
+ stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, check=False
+ )
+ if videotoolbox_check.returncode == 0:
+ return "videotoolbox"
+ except Exception:
+ pass
+
+ # 检查Intel Quick Sync支持
+ try:
+ qsv_check = subprocess.run(
+ ["ffmpeg", "-hwaccel", "qsv", "-i", "/dev/null", "-f", "null", "-"],
+ stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, check=False
+ )
+ if qsv_check.returncode == 0:
+ return "qsv"
+ except Exception:
+ pass
+
+ return None
+
+
+def clip_video(
+ video_origin_path: str,
+ tts_result: List[Dict],
+ output_dir: Optional[str] = None,
+ task_id: Optional[str] = None
+) -> Dict[str, str]:
+ """
+ 根据时间戳裁剪视频
+
+ Args:
+ video_origin_path: 原始视频的路径
+ tts_result: 包含时间戳和持续时间信息的列表
+ output_dir: 输出目录路径,默认为None时会自动生成
+ task_id: 任务ID,用于生成唯一的输出目录,默认为None时会自动生成
+
+ Returns:
+ Dict[str, str]: 时间戳到裁剪后视频路径的映射
+ """
+ # 检查视频文件是否存在
+ if not os.path.exists(video_origin_path):
+ raise FileNotFoundError(f"视频文件不存在: {video_origin_path}")
+
+ # 如果未提供task_id,则根据输入生成一个唯一ID
+ if task_id is None:
+ content_for_hash = f"{video_origin_path}_{json.dumps(tts_result)}"
+ task_id = hashlib.md5(content_for_hash.encode()).hexdigest()
+
+ # 设置输出目录
+ if output_dir is None:
+ output_dir = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
+ "storage", "temp", "clip_video", task_id
+ )
+
+ # 确保输出目录存在
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
+
+ # 检查硬件加速支持
+ hwaccel = check_hardware_acceleration()
+ hwaccel_args = []
+ if hwaccel:
+ hwaccel_args = ["-hwaccel", hwaccel]
+ logger.info(f"使用硬件加速: {hwaccel}")
+
+ # 存储裁剪结果
+ result = {}
+
+ for item in tts_result:
+ _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(',', '.')
+
+ # 格式化输出文件名(使用连字符替代冒号和逗号)
+ 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", ffmpeg_start_time,
+ "-to", ffmpeg_end_time,
+ "-c:v", "h264_videotoolbox" if hwaccel == "videotoolbox" else "libx264",
+ "-c:a", "aac",
+ "-strict", "experimental",
+ output_path
+ ]
+
+ # 执行FFmpeg命令
+ try:
+ logger.info(f"裁剪视频片段: {timestamp} -> {ffmpeg_start_time}到{ffmpeg_end_time}")
+ # logger.debug(f"执行命令: {' '.join(ffmpeg_cmd)}")
+
+ process = subprocess.run(
+ ffmpeg_cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=True
+ )
+
+ result[_id] = output_path
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"裁剪视频片段失败: {timestamp}")
+ logger.error(f"错误信息: {e.stderr}")
+ raise RuntimeError(f"视频裁剪失败: {e.stderr}")
+
+ return result
+
+
+if __name__ == "__main__":
+ video_origin_path = "/Users/apple/Desktop/home/NarratoAI/resource/videos/qyn2-2无片头片尾.mp4"
+
+ tts_result = [{'timestamp': '00:00:00-00:01:15',
+ 'audio_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3',
+ 'subtitle_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_00_00-00_01_15.srt',
+ 'duration': 25.55,
+ 'text': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!'},
+ {'timestamp': '00:01:15-00:04:40',
+ 'audio_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3',
+ 'subtitle_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_01_15-00_04_40.srt',
+ 'duration': 13.488,
+ 'text': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…'},
+ {'timestamp': '00:04:58-00:05:45',
+ 'audio_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_04_58-00_05_45.mp3',
+ 'subtitle_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_04_58-00_05_45.srt',
+ 'duration': 21.363,
+ 'text': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!'},
+ {'timestamp': '00:05:45-00:06:00',
+ 'audio_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_05_45-00_06_00.mp3',
+ 'subtitle_file': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/subtitle_00_05_45-00_06_00.srt',
+ 'duration': 7.675, 'text': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!'}]
+ subclip_path_videos = {
+ '00:00:00-00:01:15': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-00-00-00-01-15.mp4',
+ '00:01:15-00:04:40': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-01-15-00-04-40.mp4',
+ '00:04:41-00:04:58': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-41-00-04-58.mp4',
+ '00:04:58-00:05:45': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-58-00-05-45.mp4',
+ '00:05:45-00:06:00': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-05-45-00-06-00.mp4',
+ '00:06:00-00:06:03': '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-06-00-00-06-03.mp4',
+ }
+
+ # 使用方法示例
+ try:
+ result = clip_video(video_origin_path, tts_result, subclip_path_videos)
+ print("裁剪结果:")
+ print(json.dumps(result, indent=4, ensure_ascii=False))
+ except Exception as e:
+ print(f"发生错误: {e}")
diff --git a/app/services/generate_narration_script.py b/app/services/generate_narration_script.py
new file mode 100644
index 0000000..f21aa6a
--- /dev/null
+++ b/app/services/generate_narration_script.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+'''
+@Project: NarratoAI
+@File : 生成介绍文案
+@Author : 小林同学
+@Date : 2025/5/8 上午11:33
+'''
+
+import json
+import os
+import traceback
+from openai import OpenAI
+from loguru import logger
+
+
+def parse_frame_analysis_to_markdown(json_file_path):
+ """
+ 解析视频帧分析JSON文件并转换为Markdown格式
+
+ :param json_file_path: JSON文件路径
+ :return: Markdown格式的字符串
+ """
+ # 检查文件是否存在
+ if not os.path.exists(json_file_path):
+ return f"错误: 文件 {json_file_path} 不存在"
+
+ try:
+ # 读取JSON文件
+ with open(json_file_path, 'r', encoding='utf-8') as file:
+ data = json.load(file)
+
+ # 初始化Markdown字符串
+ markdown = ""
+
+ # 获取总结和帧观察数据
+ summaries = data.get('overall_activity_summaries', [])
+ frame_observations = data.get('frame_observations', [])
+
+ # 按批次组织数据
+ batch_frames = {}
+ for frame in frame_observations:
+ batch_index = frame.get('batch_index')
+ if batch_index not in batch_frames:
+ batch_frames[batch_index] = []
+ batch_frames[batch_index].append(frame)
+
+ # 生成Markdown内容
+ for i, summary in enumerate(summaries, 1):
+ batch_index = summary.get('batch_index')
+ time_range = summary.get('time_range', '')
+ batch_summary = summary.get('summary', '')
+
+ markdown += f"## 片段 {i}\n"
+ markdown += f"- 时间范围:{time_range}\n"
+
+ # 添加片段描述
+ markdown += f"- 片段描述:{batch_summary}\n" if batch_summary else f"- 片段描述:\n"
+
+ markdown += "- 详细描述:\n"
+
+ # 添加该批次的帧观察详情
+ frames = batch_frames.get(batch_index, [])
+ for frame in frames:
+ timestamp = frame.get('timestamp', '')
+ observation = frame.get('observation', '')
+
+ # 直接使用原始文本,不进行分割
+ markdown += f" - {timestamp}: {observation}\n" if observation else f" - {timestamp}: \n"
+
+ markdown += "\n"
+
+ return markdown
+
+ except Exception as e:
+ return f"处理JSON文件时出错: {traceback.format_exc()}"
+
+
+def generate_narration(markdown_content, api_key, base_url, model):
+ """
+ 调用OpenAI API根据视频帧分析的Markdown内容生成解说文案
+
+ :param markdown_content: Markdown格式的视频帧分析内容
+ :param api_key: OpenAI API密钥
+ :param base_url: API基础URL,如果使用非官方API
+ :param model: 使用的模型名称
+ :return: 生成的解说文案
+ """
+ try:
+ # 构建提示词
+ prompt = """
+我是一名荒野建造解说的博主,以下是一些同行的对标文案,请你深度学习并总结这些文案的风格特点跟内容特点:
+
+
+解压助眠的天花板就是荒野建造,沉浸丝滑的搭建过程可以说每一帧都是极致享受,我保证强迫症来了都找不出一丁点毛病。更别说全屋严丝合缝的拼接工艺,还能轻松抵御零下二十度气温,让你居住的每一天都温暖如春。
+在家闲不住的西姆今天也打算来一次野外建造,行走没多久他就发现许多倒塌的树,任由它们自生自灭不如将其利用起来。想到这他就开始挥舞铲子要把地基挖掘出来,虽然每次只能挖一点点,但架不住他体能惊人。没多长时间一个 2x3 的深坑就赫然出现,这深度住他一人绰绰有余。
+随后他去附近收集来原木,这些都是搭建墙壁的最好材料。而在投入使用前自然要把表皮刮掉,防止森林中的白蚁蛀虫。处理好一大堆后西姆还在两端打孔,使用木钉固定在一起。这可不是用来做墙壁的,而是做庇护所的承重柱。只要木头间的缝隙足够紧密,那搭建出的木屋就能足够坚固。
+每向上搭建一层,他都会在中间塞入苔藓防寒,保证不会泄露一丝热量。其他几面也是用相同方法,很快西姆就做好了三面墙壁,每一根木头都极其工整,保证强迫症来了都要点个赞再走。
+在继续搭建墙壁前西姆决定将壁炉制作出来,毕竟森林夜晚的气温会很低,保暖措施可是重中之重。完成后他找来一块大树皮用来充当庇护所的大门,而上面刮掉的木屑还能作为壁炉的引火物,可以说再完美不过。
+测试了排烟没问题后他才开始搭建最后一面墙壁,这一面要预留门和窗,所以在搭建到一半后还需要在原木中间开出卡口,让自己劈砍时能轻松许多。此时只需将另外一根如法炮制,两端拼接在一起后就是一扇大小适中的窗户。而随着随后一层苔藓铺好,最后一根原木落位,这个庇护所的雏形就算完成。
+大门的安装他没选择用合页,而是在底端雕刻出榫头,门框上则雕刻出榫眼,只能说西姆的眼就是一把尺,这完全就是严丝合缝。此时他才开始搭建屋顶。这里西姆用的方法不同,他先把最外围的原木固定好,随后将原木平铺在上面,就能得到完美的斜面屋顶。等他将四周的围栏也装好后,工整的屋顶看起来十分舒服,西姆躺上去都不想动。
+稍作休息后,他利用剩余的苔藓,对屋顶的缝隙处密封。可这样西姆觉得不够保险,于是他找来一些黏土,再次对原本的缝隙二次加工,保管这庇护所冬天也暖和。最后只需要平铺上枯叶,以及挖掘出的泥土,整个屋顶就算完成。
+考虑到庇护所的美观性,自然少不了覆盖上苔藓,翠绿的颜色看起来十分舒服。就连门口的庭院旁,他都移植了许多小树做点缀,让这木屋与周边环境融为一体。西姆才刚完成好这件事,一场大雨就骤然降临。好在此时的他已经不用淋雨,更别说这屋顶防水十分不错,室内没一点雨水渗透进来。
+等待温度回升的过程,西姆利用墙壁本身的凹槽,把床框镶嵌在上面,只需要铺上苔藓,以及自带的床单枕头,一张完美的单人床就做好。辛苦劳作一整天,西姆可不会亏待自己。他将自带的牛肉腌制好后,直接放到壁炉中烤,只需要等待三十分钟,就能享受这美味的一顿。
+在辛苦建造一星期后,他终于可以在自己搭建的庇护所中,享受最纯正的野外露营。后面西姆回家补给了一堆物资,再次回来时森林已经大雪纷飞,让他原本翠绿的小屋,更换上了冬季限定皮肤。好在内部设施没受什么影响,和他离开时一样整洁。
+就是房间中已经没多少柴火,让西姆今天又得劈柴。寒冷干燥的天气,让木头劈起来十分轻松。没多久他就收集到一大堆,这些足够燃烧好几天。虽然此时外面大雪纷飞,但小屋中却开始逐渐温暖。这次他除了带来一些食物外,还有几瓶调味料,以及一整套被褥,让自己的居住舒适度提高一大截。
+而秋天他有收集干草的缘故,只需要塞入枕套中密封起来,就能作为靠垫用。就这居住条件,比一般人在家过的还要奢侈。趁着壁炉木头变木炭的过程,西姆则开始不紧不慢的处理食物。他取出一块牛排,改好花刀以后,撒上一堆调料腌制起来。接着用锡纸包裹好,放到壁炉中直接炭烤,搭配上自带的红酒,是一个非常好的选择。
+随着时间来到第二天,外面的积雪融化了不少,西姆简单做顿煎蛋补充体力后,决定制作一个室外篝火堆,用来晚上驱散周边野兽。搭建这玩意没什么技巧,只需要找到一大堆木棍,利用大树的夹缝将其掰弯,然后将其堆积在一起,就是一个简易版的篝火堆。看这外形有点像帐篷,好在西姆没想那么多。
+等待天色暗淡下来后,他才来到室外将其点燃,顺便处理下多余的废料。只可惜这场景没朋友陪在身边,对西姆来说可能是个遗憾。而哪怕森林只有他一个人,都依旧做了好几个小时。等到里面的篝火彻底燃尽后,西姆还找来雪球,覆盖到上面将火熄灭,这防火意识可谓十分好。最后在室内二十五度的高温下,裹着被子睡觉。
+
+
+
+解压助眠的天花板就是荒野建造,沉浸丝滑的搭建过程每一帧都是极致享受,全屋严丝合缝的拼接工艺,能轻松抵御零下二十度气温,居住体验温暖如春。
+在家闲不住的西姆开启野外建造。他发现倒塌的树,决定加以利用。先挖掘出 2x3 的深坑作为地基,接着收集原木,刮掉表皮防白蚁蛀虫,打孔用木钉固定制作承重柱。搭建墙壁时,每一层都塞入苔藓防寒,很快做好三面墙。
+为应对森林夜晚低温,西姆制作壁炉,用大树皮当大门,刮下的木屑做引火物。搭建最后一面墙时预留门窗,通过在原木中间开口拼接做出窗户。大门采用榫卯结构安装,严丝合缝。
+搭建屋顶时,先固定外围原木,再平铺原木形成斜面屋顶,之后用苔藓、黏土密封缝隙,铺上枯叶和泥土。为美观,在木屋覆盖苔藓,移植小树点缀。完工时遇大雨,木屋防水良好。
+西姆利用墙壁凹槽镶嵌床框,铺上苔藓、床单枕头做成床。劳作一天后,他用壁炉烤牛肉享用。建造一星期后,他开始野外露营。
+后来西姆回家补给物资,回来时森林大雪纷飞。他劈柴储备,带回食物、调味料和被褥,提高居住舒适度,还用干草做靠垫。他用壁炉烤牛排,搭配红酒。
+第二天,积雪融化,西姆制作室外篝火堆防野兽。用大树夹缝掰弯木棍堆积而成,晚上点燃处理废料,结束后用雪球灭火,最后在室内二十五度的环境中裹被入睡。
+
+
+
+如果战争到来,这个深埋地下十几米的庇护所绝对是 bug 般的存在。即使被敌人发现,还能通过快速通道一秒逃出。里面不仅有竹子、地暖、地下水井,还自制抽水机。在解决用水问题的同时,甚至自研无土栽培技术,过上完全自给自足的生活。
+阿伟的老婆美如花,但阿伟从来不回家,来到野外他乐哈哈,一言不合就开挖。众所周知当战争来临时,地下堡垒的安全性是最高的。阿伟苦苦研习两载半,只为练就一身挖洞本领。在这双逆天麒麟臂的加持下,如此坚硬的泥土都只能当做炮灰。
+得到了充足的空间后,他便开始对这些边缘进行打磨。随后阿伟将细线捆在木棍上,以此描绘出圆柱的轮廓。接着再一点点铲掉多余的部分。虽然是由泥土一体式打造,但这样的桌子保准用上千年都不成问题。
+考虑到十几米的深度进出非常不方便,于是阿伟找来两根长达 66.6 米的木头,打算为庇护所打造一条快速通道。只见他将木桩牢牢地插入地下,并顺着洞口的方向延伸出去,直到贯穿整个山洞。接着在每个木桩的连接处钉入铁钉,确保轨道不能有一毫米的偏差。完成后再制作一个木质框架,从而达到前后滑动的效果。
+不得不说阿伟这手艺简直就是大钢管子杵青蛙。在上面放上一个木制的车斗,还能加快搬运泥土的速度。没多久庇护所的内部就已经初见雏形。为了住起来更加舒适,还需要为自己打造一张床。虽然深处的泥土同样很坚固,但好处就是不用担心垮塌的风险。
+阿伟不仅设计了更加符合人体工学的拱形,并且还在一旁雕刻处壁龛。就是这氛围怎么看着有点不太吉利。别看阿伟一身腱子肉,但这身体里的艺术细菌可不少。每个边缘的地方他都做了精雕细琢,瞬间让整个卧室的颜值提升一大截。
+住在地下的好处就是房子面积全靠挖,每平方消耗两个半馒头。不仅没有了房贷的压力,就连买墓地的钱也省了。阿伟将中间的墙壁挖空,从而得到取暖的壁炉。当然最重要的还有排烟问题,要想从上往下打通十几米的山体是件极其困难的事。好在阿伟年轻时报过忆坤年的古墓派补习班,这打洞技术堪比隔壁学校的土拨鼠专业。虽然深度长达十几米,但排烟效果却一点不受影响,一个字专业!
+随后阿伟继续对壁炉底部雕刻,打通了底部放柴火的空间,并制作出放锅的灶头。完成后阿伟从侧面将壁炉打通,并制作出一条导热的通道,以此连接到床铺的位置。毕竟住在这么一个风湿宝地,不注意保暖除湿很容易得老寒腿。
+阿伟在床面上挖出一条条管道,以便于温度能传输到床的每个角落。接下来就可以根据这些通道的长度裁切出同样长短的竹子,根据竹筒的大小凿出相互连接的孔洞,最后再将竹筒内部打通,以达到温度传送的效果。
+而后阿伟将这些管道安装到凹槽内,在他严谨的制作工艺下,每根竹子刚好都能镶嵌进去。在铺设床面之前还需要用木塞把圆孔堵住,防止泥土掉落进管道。泥土虽然不能隔绝湿气,但却是十分优良的导热材料。等他把床面都压平后就可以小心的将这些木塞拔出来,最后再用黏土把剩余的管道也遮盖起来,直到整个墙面恢复原样。
+接下来还需要测试一下加热效果,当他把火点起来后,温度很快就传送到了管道内,把火力一点点加大,直到热气流淌到更远的床面。随着小孔里的青烟冒出,也预示着阿伟的地暖可以投入使用。而后阿伟制作了一些竹条,并用细绳将它们喜结连理。
+千里之行始于足下,美好的家园要靠自己双手打造。明明可以靠才艺吃饭的阿伟偏偏要用八块腹肌征服大家,就问这样的男人哪个野生婆娘不喜欢?完成后阿伟还用自己 35 码的大腚感受了一下,真烫!
+随后阿伟来到野区找到一根上好的雷击木,他当即就把木头咔嚓成两段,并取下两节较为完整的带了回去,刚好能和圆桌配套。另外一个在里面凿出凹槽,并插入木棍连接,得到一个夯土的木锤。住过农村的小伙伴都知道,这样夯出来的地面堪比水泥地,不仅坚硬耐磨,还不用担心脚底打滑。忙碌了一天的阿伟已经饥渴难耐,拿出野生小烤肠,安安心心住新房,光脚爬上大热炕,一觉能睡到天亮。
+第二天阿伟打算将房间扩宽,毕竟吃住的地方有了,还要解决个人卫生的问题。阿伟在另一侧增加了一个房间,他打算将这里打造成洗澡的地方。为了防止泥土垮塌,他将顶部做成圆弧形,等挖出足够的空间后,旁边的泥土已经堆成了小山。
+为了方便清理这些泥土,阿伟在之前的轨道增加了转弯,交接处依然是用铁钉固定,一直延伸到房间的最里面。有了运输车的帮助,这些成吨的泥土也能轻松的运送出去,并且还能体验过山车的感觉。很快他就完成了清理工作。
+为了更方便的在里面洗澡,他将底部一点点挖空,这么大的浴缸,看来阿伟并不打算一个人住。完成后他将墙面雕刻的凹凸有致,让这里看起来更加豪华。接着用洛阳铲挖出排水口,并用一根相同大小的竹筒作为开关。
+由于四周都是泥土还不能防水,阿伟特意找了一些白蚁巢,用来制作可以防水的野生水泥。现在就可以将里里外外,能接触到水的地方都涂抹一遍。细心的阿伟还找来这种 500 克一斤的鹅卵石,对池子表面进行装饰。
+没错,水源问题阿伟早已经考虑在内,他打算直接在旁边挖个水井,毕竟已经挖了这么深,再向下挖一挖,应该就能到达地下水的深度。经过几日的奋战,能看得出阿伟已经消瘦了不少,但一想到马上就能拥有的豪宅,他直接化身为无情的挖土机器,很快就挖到了好几米的深度。
+考虑到自己的弹跳力有限,阿伟在一旁定入木桩,然后通过绳子爬上爬下。随着深度越来越深,井底已经开始渗出水来,这也预示着打井成功。没多久这里面将渗满泉水,仅凭一次就能挖到水源,看来这里还真是块风湿宝地。
+随后阿伟在井口四周挖出凹槽,以便于井盖的安置。这一量才知道,井的深度已经达到了足足的 5 米。阿伟把木板组合在一起,再沿着标记切掉多余部分,他甚至还给井盖做了把手。可是如何从这么深的井里打水还是个问题,但从阿伟坚定的眼神来看,他应该想到了解决办法。
+只见他将树桩锯成两半,然后用凿子把里面一点点掏空,另外一半也是如法炮制。接着还要在底部挖出圆孔,要想成功将水从 5 米深的地方抽上来,那就不得不提到大家熟知的勾股定理。没错,这跟勾股定理没什么关系。
+阿伟给竹筒做了一个木塞,并在里面打上安装连接轴的孔。为了增加密闭性,阿伟不得不牺牲了自己的 AJ,剪出与木塞相同的大小后,再用木钉固定住。随后他收集了一些树胶,并放到火上加热融化。接下来就可以涂在木塞上增加使用寿命。
+现在将竹筒组装完成,就可以利用虹吸原理将水抽上来。完成后就可以把井盖盖上去,再用泥土在上面覆盖,现在就不用担心失足掉下去了。
+接下来阿伟去采集了一些大漆,将它涂抹在木桶接缝处,就能将其二合为一。完了再接入旁边浴缸的入水口,每个连接的地方都要做好密封,不然后面很容易漏水。随后就可以安装上活塞,并用一根木桩作为省力杠杆,根据空气压强的原理将井水抽上来。
+经过半小时的来回拉扯,硕大的浴缸终于被灌满,阿伟也是忍不住洗了把脸。接下来还需要解决排水的问题,阿伟在地上挖出沟渠,一直贯穿到屋外,然后再用竹筒从出水口连接,每个接口处都要抹上胶水,就连门外的出水口他都做了隐藏。
+在野外最重要的就是庇护所、水源还有食物。既然已经完成了前二者,那么阿伟还需要拥有可持续发展的食物来源。他先是在地上挖了两排地洞,然后在每根竹筒的表面都打上无数孔洞,这就是他打算用来种植的载体。在此之前,还需要用大火对竹筒进行杀菌消毒。
+趁着这时候,他去搬了一麻袋的木屑,先用芭蕉叶覆盖在上面,再铺上厚厚的黏土隔绝温度。在火焰的温度下,能让里面的木屑达到生长条件。
+等到第二天所有材料都晾凉后,阿伟才将竹筒内部掏空,并将木屑一点点地塞入竹筒。一切准备就绪,就可以将竹筒插入提前挖好的地洞。最后再往竹筒里塞入种子,依靠房间内的湿度和温度,就能达到大棚种植的效果。稍加时日,这些种子就会慢慢发芽。
+虽然暂时还吃不上自己培养的食物,但好在阿伟从表哥贺强那里学到不少钓鱼本领,哪怕只有一根小小的竹竿,也能让他钓上两斤半的大鲶鱼。新鲜的食材,那肯定是少不了高温消毒的过程。趁着鱼没熟,阿伟直接爬进浴缸,冰凉的井水瞬间洗去了身上的疲惫。这一刻的阿伟是无比的享受。
+不久后鱼也烤得差不多了,阿伟的生活现在可以说是有滋有味。住在十几米的地下,不仅能安全感满满,哪怕遇到危险,还能通过轨道快速逃生。
+
+
+
+%s
+
+
+我正在尝试做这个内容的解说纪录片视频,我需要你以 中的内容为解说目标,根据我刚才提供给你的对标文案 特点,以及你总结的特点,帮我生成一段关于荒野建造的解说文案,文案需要符合平台受欢迎的解说风格,请使用 json 格式进行输出;使用