Spaces:
Running
Running
first commit
Browse files- .gitignore +27 -0
- .streamlit/config.toml +3 -0
- README.md +103 -11
- app.py +308 -0
- requirements.txt +8 -0
- stt_module.py +71 -0
- translator.py +36 -0
- utils.py +86 -0
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# Environment variables
|
| 7 |
+
.env
|
| 8 |
+
|
| 9 |
+
# Local models
|
| 10 |
+
models/
|
| 11 |
+
models/*
|
| 12 |
+
|
| 13 |
+
# Streamlit config (optional, if you want to ignore local server config)
|
| 14 |
+
#.streamlit/
|
| 15 |
+
|
| 16 |
+
# Video processing temp files
|
| 17 |
+
*.mp4
|
| 18 |
+
*.mp3
|
| 19 |
+
*.wav
|
| 20 |
+
*.srt
|
| 21 |
+
*.mkv
|
| 22 |
+
*.avi
|
| 23 |
+
*.mov
|
| 24 |
+
|
| 25 |
+
# IDE
|
| 26 |
+
.vscode/
|
| 27 |
+
.idea/
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[server]
|
| 2 |
+
maxUploadSize = 10240
|
| 3 |
+
maxMessageSize = 10240
|
README.md
CHANGED
|
@@ -1,11 +1,103 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎬 AI 媒体翻译专家 (Media Translator)
|
| 2 |
+
|
| 3 |
+
这是一个基于 Python 的视频/音频翻译工具,可以自动提取媒体音频、识别语音生成字幕、通过 AI 翻译字幕,并最终将字幕压制回视频中(仅视频)。支持本地 CPU 运行语音识别,翻译部分支持对接各种主流 AI API。
|
| 4 |
+
|
| 5 |
+
## ✨ 功能特点
|
| 6 |
+
|
| 7 |
+
- **全媒体支持**:支持视频(mp4, mkv, avi, mov)和音频(mp3, wav, m4a, flac, aac)文件。
|
| 8 |
+
- **本地语音识别 (STT)**:使用 `faster-whisper` 模型,在本地即可完成语音转文字,支持自动检测语言。
|
| 9 |
+
- **直接字幕翻译**:支持直接上传已有的 `.srt` 字幕文件进行翻译,无需重新处理视频。
|
| 10 |
+
- **AI 智能翻译**:兼容 OpenAI 格式的 API(如 SiliconFlow、Zenmux、DeepSeek 等),支持自定义 API 地址和模型。
|
| 11 |
+
- **视频硬压字幕**:自动将翻译后的字幕嵌入到视频中。
|
| 12 |
+
- **多种导出格式**:支持导出原始 SRT 字幕、翻译后的 SRT 字幕以及压制好的 MP4 视频。
|
| 13 |
+
- **低硬件门槛**:经过优化,即使在没有显卡(仅 CPU)的普通电脑上也能流畅运行。
|
| 14 |
+
|
| 15 |
+
## 🛠️ 环境准备
|
| 16 |
+
|
| 17 |
+
在运行本项目之前,请确保已安装以下工具:
|
| 18 |
+
|
| 19 |
+
### 1. FFmpeg (核心依赖)
|
| 20 |
+
FFmpeg 负责音频提取和视频合成,是必须安装的。
|
| 21 |
+
- **Windows**:
|
| 22 |
+
1. 下载 [FFmpeg 编译版](https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-full.7z)。
|
| 23 |
+
2. 解压并将 `bin` 目录路径添加到系统的 **环境变量 (PATH)** 中。(在打开的“编辑环境变量”窗口中,窗口的下半部分“**系统变量**”区域,找到并选中名为 **`Path`** 的变量。点击“新建”,将你电脑上 FFmpeg 的 bin 文件夹的完整路径粘贴进去。例如:D:\ffmpeg\bin 或 C:\Tools\ffmpeg-6.1-full_build\bin)
|
| 24 |
+
3. 验证:在终端输入 `ffmpeg -version` 确认是否有输出。
|
| 25 |
+
- **macOS**: `brew install ffmpeg`
|
| 26 |
+
- **Linux**: `sudo apt install ffmpeg`
|
| 27 |
+
|
| 28 |
+
### 2. Python 3.8+
|
| 29 |
+
建议使用 Python 3.10 或更高版本。
|
| 30 |
+
|
| 31 |
+
## 🚀 安装与运行
|
| 32 |
+
|
| 33 |
+
1. **克隆或下载本项目**到本地。
|
| 34 |
+
|
| 35 |
+
2. **安装依赖库**:
|
| 36 |
+
```bash
|
| 37 |
+
pip install -r requirements.txt
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
3. **启动程序**:
|
| 41 |
+
```bash
|
| 42 |
+
streamlit run app.py
|
| 43 |
+
```
|
| 44 |
+
启动后,浏览器会自动打开 `http://localhost:8501`。
|
| 45 |
+
|
| 46 |
+
**注意**:为了支持大视频上传,本项目已在 `.streamlit/config.toml` 中配置了最大 10GB 的上传限制。
|
| 47 |
+
|
| 48 |
+
## ⚙️ 自动保存配置 (.env)
|
| 49 |
+
|
| 50 |
+
为了避免每次运行都要手动输入 API Key,你可以创建一个名为 `.env` 的文件(可以参考项目中的 `.env.example`):
|
| 51 |
+
|
| 52 |
+
1. 在项目根目录下新建文件 `.env`。
|
| 53 |
+
2. 填写以下内容:
|
| 54 |
+
```env
|
| 55 |
+
API_KEY=你的_API_KEY
|
| 56 |
+
BASE_URL=https://api.siliconflow.cn/v1
|
| 57 |
+
MODEL_NAME=THUDM/glm-4-9b-chat
|
| 58 |
+
STT_MODEL_SIZE=base
|
| 59 |
+
TARGET_LANG=中文
|
| 60 |
+
```
|
| 61 |
+
3. 下次启动程序时,这些值将自动填充到界面中。
|
| 62 |
+
|
| 63 |
+
## 📖 使用指南
|
| 64 |
+
|
| 65 |
+
1. **配置 API**:
|
| 66 |
+
- 在左侧边栏输入你的 **API Key**。
|
| 67 |
+
- 如果使用 SiliconFlow,默认地址为 `https://api.siliconflow.cn/v1`。
|
| 68 |
+
- 输入你想要使用的模型名称(例如 `THUDM/glm-4-9b-chat` 或 `deepseek-chat`)。
|
| 69 |
+
|
| 70 |
+
2. **上传媒体文件**:
|
| 71 |
+
- 点击或拖拽视频(mp4, mkv, avi, mov)或音频(mp3, wav, m4a 等)文件到上传区。
|
| 72 |
+
|
| 73 |
+
3. **开始处理**:
|
| 74 |
+
- 选择本地 STT 模型(建议 CPU 使用 `base` 或 `small`)。
|
| 75 |
+
- 选择目标翻译语言(默认为中文)。
|
| 76 |
+
- 点击 **“开始处理”**。
|
| 77 |
+
|
| 78 |
+
4. **获取结果**:
|
| 79 |
+
- 处理完成后,你可以分别下载:原始字幕、翻译字幕、带字幕的视频。
|
| 80 |
+
|
| 81 |
+
## � 文件存储与清理
|
| 82 |
+
|
| 83 |
+
为了确保处理流程的稳定性,程序在运行过程中会产生一些临时文件:
|
| 84 |
+
|
| 85 |
+
- **存储位置**:所有上传的文件和中间产物(音频、临时字幕、合成视频)都保存在**操作系统的默认临时目录**中。
|
| 86 |
+
- **Windows**: `C:\Users\你的用户名\AppData\Local\Temp`
|
| 87 |
+
- **macOS/Linux**: `/tmp` 或 `$TMPDIR`
|
| 88 |
+
- **清理机制**:程序**不会自动删除**这些临时文件。
|
| 89 |
+
- **手动清理**:如果处理的文件较多或较大,建议定期手动清理系统临时文件夹中以 `tmp` 开头的文件,以释放磁盘空间。
|
| 90 |
+
|
| 91 |
+
## �� 项目结构
|
| 92 |
+
|
| 93 |
+
- `app.py`: Streamlit Web 界面主程序。
|
| 94 |
+
- `stt_module.py`: 封装了 `faster-whisper` 的语音识别逻辑。
|
| 95 |
+
- `translator.py`: 封装了基于 OpenAI 协议的 AI 翻译逻辑。
|
| 96 |
+
- `utils.py`: 封装了 FFmpeg 相关的视频与音频处理工具。
|
| 97 |
+
- `requirements.txt`: 项目所需的 Python 依赖列表。
|
| 98 |
+
|
| 99 |
+
## ⚠️ 注意事项
|
| 100 |
+
|
| 101 |
+
- **首次运行**:第一次使用某种 STT 模型(如 `base`)时,程序会自动从 HuggingFace 下���模型文件,请确保网络通畅。
|
| 102 |
+
- **性能**:如果电脑配置较低,建议使用 `tiny` 或 `base` 模型以加快识别速度。
|
| 103 |
+
- **翻译额度**:翻译功能依赖外部 API,请确保你的 API 账户有足够余额或额度。
|
app.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
from utils import VideoUtils
|
| 4 |
+
from stt_module import STTManager
|
| 5 |
+
from translator import Translator
|
| 6 |
+
import tempfile
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
# 加载 .env 文件中的配置
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
# 取消 Streamlit 上传限制(虽然主要通过命令行配置,但在脚本中提醒用户)
|
| 13 |
+
# 实际上 Streamlit 的服务器配置需要在运行命令时指定,或者写在 config.toml 中
|
| 14 |
+
# 我们这里先通过 UI 提醒用户
|
| 15 |
+
|
| 16 |
+
st.set_page_config(page_title="AI 媒体翻译专家", layout="wide")
|
| 17 |
+
|
| 18 |
+
st.title("🎬 AI 媒体字幕提取与翻译")
|
| 19 |
+
|
| 20 |
+
# 侧边栏配置
|
| 21 |
+
with st.sidebar:
|
| 22 |
+
st.header("⚙️ 配置参数")
|
| 23 |
+
|
| 24 |
+
# 从环境变量读取默认值,如果没有则为空字符串或预设值
|
| 25 |
+
default_api_key = os.getenv("API_KEY", "")
|
| 26 |
+
default_base_url = os.getenv("BASE_URL", "https://api.siliconflow.cn/v1")
|
| 27 |
+
default_model = os.getenv("MODEL_NAME", "THUDM/glm-4-9b-chat")
|
| 28 |
+
default_stt_size = os.getenv("STT_MODEL_SIZE", "base")
|
| 29 |
+
default_lang = os.getenv("TARGET_LANG", "中文")
|
| 30 |
+
default_device = os.getenv("DEVICE", "cpu")
|
| 31 |
+
|
| 32 |
+
api_key = st.text_input("API Key", value=default_api_key, type="password", help="输入 API Key")
|
| 33 |
+
base_url = st.text_input("API Base URL", value=default_base_url)
|
| 34 |
+
model_name = st.text_input("模型名称", value=default_model)
|
| 35 |
+
|
| 36 |
+
st.divider()
|
| 37 |
+
|
| 38 |
+
# 设备选择
|
| 39 |
+
cuda_available = STTManager.is_cuda_available()
|
| 40 |
+
device_options = ["cpu", "cuda"] if cuda_available else ["cpu"]
|
| 41 |
+
device_index = device_options.index(default_device) if default_device in device_options else 0
|
| 42 |
+
device = st.selectbox(
|
| 43 |
+
"计算设备 (Device)",
|
| 44 |
+
device_options,
|
| 45 |
+
index=device_index,
|
| 46 |
+
help="如果有 NVIDIA 显卡且安装了 CUDA,选择 'cuda' 会大幅提升速度。"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# 根据设备推荐精度
|
| 50 |
+
default_compute_type = "float16" if device == "cuda" else "int8"
|
| 51 |
+
compute_type = st.selectbox(
|
| 52 |
+
"计算精度 (Compute Type)",
|
| 53 |
+
["int8", "float16", "int8_float16"],
|
| 54 |
+
index=1 if device == "cuda" else 0,
|
| 55 |
+
help="CPU 推荐 int8,GPU 推荐 float16。"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
stt_options = ["tiny", "base", "small", "medium", "large-v3"]
|
| 59 |
+
# 获取已下载模型
|
| 60 |
+
downloaded_models = STTManager.get_downloaded_models()
|
| 61 |
+
|
| 62 |
+
# 构建选项显示名称
|
| 63 |
+
option_labels = []
|
| 64 |
+
for opt in stt_options:
|
| 65 |
+
label = f"{opt} (已下载)" if opt in downloaded_models else opt
|
| 66 |
+
option_labels.append(label)
|
| 67 |
+
|
| 68 |
+
stt_index = stt_options.index(default_stt_size) if default_stt_size in stt_options else 1
|
| 69 |
+
selected_option = st.selectbox(
|
| 70 |
+
"本地 STT 模型大小",
|
| 71 |
+
option_labels,
|
| 72 |
+
index=stt_index,
|
| 73 |
+
help="越大越准,但速度越慢。首次使用未下载的模型时会自动下载。"
|
| 74 |
+
)
|
| 75 |
+
# 从选项中提取真实模型名
|
| 76 |
+
model_size = selected_option.split(" ")[0]
|
| 77 |
+
|
| 78 |
+
lang_options = ["中文", "English", "日本語", "Français"]
|
| 79 |
+
lang_index = lang_options.index(default_lang) if default_lang in lang_options else 0
|
| 80 |
+
target_lang = st.selectbox("目标语言", lang_options, index=lang_index)
|
| 81 |
+
|
| 82 |
+
# 主界面
|
| 83 |
+
tab1, tab2 = st.tabs(["📁 媒体处理", "📜 字幕翻译"])
|
| 84 |
+
|
| 85 |
+
if "process_results" not in st.session_state:
|
| 86 |
+
st.session_state.process_results = {}
|
| 87 |
+
if "srt_results" not in st.session_state:
|
| 88 |
+
st.session_state.srt_results = {}
|
| 89 |
+
if "awaiting_synthesis" not in st.session_state:
|
| 90 |
+
st.session_state.awaiting_synthesis = False
|
| 91 |
+
|
| 92 |
+
with tab1:
|
| 93 |
+
st.info("💡 提示:支持视频和音频文件。如果文件非常大,处理会很慢请耐心等待。")
|
| 94 |
+
uploaded_file = st.file_uploader("选择视频或音频文件", type=["mp4", "mkv", "avi", "mov", "mp3", "wav", "m4a", "flac", "aac"])
|
| 95 |
+
|
| 96 |
+
if uploaded_file:
|
| 97 |
+
# 检测文件类型
|
| 98 |
+
video_extensions = [".mp4", ".mkv", ".avi", ".mov"]
|
| 99 |
+
file_ext = os.path.splitext(uploaded_file.name)[1].lower()
|
| 100 |
+
is_video = file_ext in video_extensions
|
| 101 |
+
|
| 102 |
+
# 如果上传了新文件且与 session_state 中记录的不同,则清除旧结果
|
| 103 |
+
if "last_uploaded_file" not in st.session_state or st.session_state.last_uploaded_file != uploaded_file.name:
|
| 104 |
+
st.session_state.process_results = {}
|
| 105 |
+
st.session_state.awaiting_synthesis = False
|
| 106 |
+
st.session_state.last_uploaded_file = uploaded_file.name
|
| 107 |
+
|
| 108 |
+
# 保存上传的文件到临时目录
|
| 109 |
+
tfile = tempfile.NamedTemporaryFile(delete=False, suffix=file_ext)
|
| 110 |
+
tfile.write(uploaded_file.read())
|
| 111 |
+
file_path = tfile.name
|
| 112 |
+
|
| 113 |
+
if is_video:
|
| 114 |
+
st.video(file_path)
|
| 115 |
+
else:
|
| 116 |
+
st.audio(file_path)
|
| 117 |
+
|
| 118 |
+
if st.button("开始处理", disabled=not api_key or st.session_state.awaiting_synthesis):
|
| 119 |
+
# 清除旧结果
|
| 120 |
+
st.session_state.process_results = {}
|
| 121 |
+
st.session_state.awaiting_synthesis = False
|
| 122 |
+
|
| 123 |
+
with st.status("正在处理中...", expanded=True) as status:
|
| 124 |
+
# 1. 提取/准备音频
|
| 125 |
+
st.write("🎵 正在准备音频...")
|
| 126 |
+
audio_path = os.path.splitext(file_path)[0] + '.wav'
|
| 127 |
+
try:
|
| 128 |
+
# 无论视频还是音频,都通过 prepare_audio (ffmpeg) 转换为标准格式,确保 STT 兼容性
|
| 129 |
+
VideoUtils.prepare_audio(file_path, audio_path)
|
| 130 |
+
except Exception as e:
|
| 131 |
+
st.error(str(e))
|
| 132 |
+
status.update(label="处理出错", state="error", expanded=True)
|
| 133 |
+
st.stop()
|
| 134 |
+
|
| 135 |
+
# 2. 本地 STT
|
| 136 |
+
if model_size not in downloaded_models:
|
| 137 |
+
st.write(f"📥 正在下载 {model_size} 模型,请稍候...")
|
| 138 |
+
|
| 139 |
+
st.write(f"✍️ 正在识别语音 (使用 {model_size} 模型,设备: {device})...")
|
| 140 |
+
stt_manager = STTManager(model_size=model_size, device=device, compute_type=compute_type)
|
| 141 |
+
stt_manager.load_model()
|
| 142 |
+
|
| 143 |
+
segments_gen, info = stt_manager.transcribe(audio_path)
|
| 144 |
+
st.write(f"检测到语言: {info.language} (置信度: {info.language_probability:.2f})")
|
| 145 |
+
|
| 146 |
+
# 增量处理与展示
|
| 147 |
+
st.write("---")
|
| 148 |
+
st.write("实时识别与翻译预览:")
|
| 149 |
+
preview_container = st.empty()
|
| 150 |
+
all_segments = []
|
| 151 |
+
all_translated_segments = []
|
| 152 |
+
|
| 153 |
+
translator = Translator(api_key, base_url, model_name)
|
| 154 |
+
|
| 155 |
+
# 用于展示的表格数据
|
| 156 |
+
display_data = []
|
| 157 |
+
|
| 158 |
+
for segment in segments_gen:
|
| 159 |
+
# 1. 翻译当前段落
|
| 160 |
+
trans_text = translator.translate_text(segment.text, target_lang)
|
| 161 |
+
|
| 162 |
+
# 2. 保存原始和翻译后的段落
|
| 163 |
+
all_segments.append(segment)
|
| 164 |
+
new_trans_seg = type('Segment', (), {
|
| 165 |
+
'start': segment.start,
|
| 166 |
+
'end': segment.end,
|
| 167 |
+
'text': trans_text
|
| 168 |
+
})
|
| 169 |
+
all_translated_segments.append(new_trans_seg)
|
| 170 |
+
|
| 171 |
+
# 3. 更新预览界面
|
| 172 |
+
time_str = f"{VideoUtils.format_timestamp(segment.start)} -> {VideoUtils.format_timestamp(segment.end)}"
|
| 173 |
+
display_data.append({
|
| 174 |
+
"时间轴": time_str,
|
| 175 |
+
"原文": segment.text,
|
| 176 |
+
"翻译": trans_text
|
| 177 |
+
})
|
| 178 |
+
# 仅显示最后 5 条,避免页面过长,但提供滚动查看全部的可能
|
| 179 |
+
preview_container.table(display_data[-5:])
|
| 180 |
+
|
| 181 |
+
# 生成原始字幕
|
| 182 |
+
orig_srt_path = os.path.splitext(file_path)[0] + '_orig.srt'
|
| 183 |
+
VideoUtils.write_srt(all_segments, orig_srt_path)
|
| 184 |
+
|
| 185 |
+
# 生成翻译字幕
|
| 186 |
+
trans_srt_path = os.path.splitext(file_path)[0] + '_trans.srt'
|
| 187 |
+
VideoUtils.write_srt(all_translated_segments, trans_srt_path)
|
| 188 |
+
|
| 189 |
+
status.update(label="处理完成!", state="complete", expanded=False)
|
| 190 |
+
|
| 191 |
+
# 保存结果到 session_state
|
| 192 |
+
st.session_state.process_results = {
|
| 193 |
+
"orig_srt": orig_srt_path,
|
| 194 |
+
"trans_srt": trans_srt_path,
|
| 195 |
+
"output_video": None,
|
| 196 |
+
"is_video": is_video
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
# 保存中间结果用于后续合成 (仅针对视频)
|
| 200 |
+
if is_video:
|
| 201 |
+
st.session_state.temp_video_path = file_path
|
| 202 |
+
st.session_state.temp_trans_srt_path = trans_srt_path
|
| 203 |
+
st.session_state.temp_orig_srt_path = orig_srt_path
|
| 204 |
+
st.session_state.awaiting_synthesis = True
|
| 205 |
+
|
| 206 |
+
st.rerun()
|
| 207 |
+
|
| 208 |
+
# 4. 嵌入视频 (仅视频且在等待状态)
|
| 209 |
+
if st.session_state.awaiting_synthesis and st.session_state.process_results.get("is_video"):
|
| 210 |
+
st.success("✅ 字幕翻译已完成!")
|
| 211 |
+
col_synth_1, col_synth_2 = st.columns(2)
|
| 212 |
+
with col_synth_1:
|
| 213 |
+
if st.button("🚀 开始合成视频字幕", type="primary"):
|
| 214 |
+
with st.status("🎬 正在合成视频字幕...", expanded=True) as status:
|
| 215 |
+
v_path = st.session_state.temp_video_path
|
| 216 |
+
s_path = st.session_state.temp_trans_srt_path
|
| 217 |
+
output_video_path = os.path.splitext(v_path)[0] + '_translated.mp4'
|
| 218 |
+
|
| 219 |
+
video_ready = False
|
| 220 |
+
try:
|
| 221 |
+
VideoUtils.embed_subtitles(v_path, s_path, output_video_path)
|
| 222 |
+
video_ready = True
|
| 223 |
+
st.write("✨ 视频合成成功!")
|
| 224 |
+
except Exception as e:
|
| 225 |
+
st.error(f"视频合成失败 (请确保已安装 FFmpeg): {e}")
|
| 226 |
+
|
| 227 |
+
status.update(label="全部处理完成!", state="complete", expanded=False)
|
| 228 |
+
|
| 229 |
+
# 保存最终结果到 session_state
|
| 230 |
+
st.session_state.process_results["output_video"] = output_video_path if video_ready else None
|
| 231 |
+
st.session_state.awaiting_synthesis = False
|
| 232 |
+
st.rerun()
|
| 233 |
+
|
| 234 |
+
with col_synth_2:
|
| 235 |
+
if st.button("📂 仅保存字幕"):
|
| 236 |
+
st.session_state.awaiting_synthesis = False
|
| 237 |
+
st.rerun()
|
| 238 |
+
|
| 239 |
+
# 结果展示与下载 (移出 button 缩进块,始终根据 session_state 显示)
|
| 240 |
+
if st.session_state.process_results:
|
| 241 |
+
st.divider()
|
| 242 |
+
col_title, col_clear = st.columns([5, 1])
|
| 243 |
+
with col_title:
|
| 244 |
+
st.subheader("🎉 处理结果")
|
| 245 |
+
with col_clear:
|
| 246 |
+
if st.button("🗑️ 清除结果"):
|
| 247 |
+
st.session_state.process_results = {}
|
| 248 |
+
st.session_state.awaiting_synthesis = False
|
| 249 |
+
st.rerun()
|
| 250 |
+
|
| 251 |
+
col1, col2, col3 = st.columns(3)
|
| 252 |
+
|
| 253 |
+
results = st.session_state.process_results
|
| 254 |
+
|
| 255 |
+
if os.path.exists(results.get("orig_srt", "")):
|
| 256 |
+
with col1:
|
| 257 |
+
with open(results["orig_srt"], "rb") as f:
|
| 258 |
+
st.download_button("⬇️ 下载原始字幕", f, file_name="original.srt", key="dl_orig")
|
| 259 |
+
|
| 260 |
+
if os.path.exists(results.get("trans_srt", "")):
|
| 261 |
+
with col2:
|
| 262 |
+
with open(results["trans_srt"], "rb") as f:
|
| 263 |
+
st.download_button("⬇️ 下载翻译字幕", f, file_name="translated.srt", key="dl_trans")
|
| 264 |
+
|
| 265 |
+
if results.get("output_video") and os.path.exists(results["output_video"]):
|
| 266 |
+
with col3:
|
| 267 |
+
with open(results["output_video"], "rb") as f:
|
| 268 |
+
st.download_button("⬇️ 下载翻译视频", f, file_name="video_with_subtitles.mp4", key="dl_video")
|
| 269 |
+
|
| 270 |
+
with tab2:
|
| 271 |
+
st.info("如果你已经有原始语言的字幕文件(SRT),可以在这里直接进行翻译。")
|
| 272 |
+
uploaded_srt = st.file_uploader("上传原始 SRT 字幕", type=["srt"])
|
| 273 |
+
|
| 274 |
+
if uploaded_srt:
|
| 275 |
+
# 如果上传了新字幕且与 session_state 中记录的不同,则清除旧结果
|
| 276 |
+
if "last_uploaded_srt" not in st.session_state or st.session_state.last_uploaded_srt != uploaded_srt.name:
|
| 277 |
+
st.session_state.srt_results = {}
|
| 278 |
+
st.session_state.last_uploaded_srt = uploaded_srt.name
|
| 279 |
+
|
| 280 |
+
srt_content = uploaded_srt.read().decode("utf-8")
|
| 281 |
+
st.text_area("字幕预览", srt_content, height=200)
|
| 282 |
+
|
| 283 |
+
if st.button("开始翻译字幕", disabled=not api_key):
|
| 284 |
+
st.session_state.srt_results = {} # 清除旧结果
|
| 285 |
+
with st.spinner("正在翻译字幕..."):
|
| 286 |
+
segments = VideoUtils.parse_srt(srt_content)
|
| 287 |
+
translator = Translator(api_key, base_url, model_name)
|
| 288 |
+
translated_segments = translator.translate_segments(segments, target_lang)
|
| 289 |
+
|
| 290 |
+
# 保存到临时文件
|
| 291 |
+
temp_trans_srt = tempfile.NamedTemporaryFile(delete=False, suffix='.srt')
|
| 292 |
+
VideoUtils.write_srt(translated_segments, temp_trans_srt.name)
|
| 293 |
+
|
| 294 |
+
st.session_state.srt_results = {
|
| 295 |
+
"trans_srt": temp_trans_srt.name
|
| 296 |
+
}
|
| 297 |
+
st.success("翻译完成!")
|
| 298 |
+
|
| 299 |
+
# 结果下载
|
| 300 |
+
if st.session_state.srt_results:
|
| 301 |
+
st.divider()
|
| 302 |
+
res = st.session_state.srt_results
|
| 303 |
+
if os.path.exists(res.get("trans_srt", "")):
|
| 304 |
+
with open(res["trans_srt"], "rb") as f:
|
| 305 |
+
st.download_button("⬇️ 下载翻译后的字幕", f, file_name="translated_only.srt", key="dl_srt_tab")
|
| 306 |
+
|
| 307 |
+
if not api_key:
|
| 308 |
+
st.warning("请在左侧边栏输入 API Key 后开始。")
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
faster-whisper
|
| 2 |
+
openai
|
| 3 |
+
pydantic
|
| 4 |
+
requests
|
| 5 |
+
tqdm
|
| 6 |
+
streamlit
|
| 7 |
+
python-dotenv
|
| 8 |
+
ffmpeg-python
|
stt_module.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from faster_whisper import WhisperModel
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
class STTManager:
|
| 5 |
+
def __init__(self, model_size="base", device="cpu", compute_type="int8"):
|
| 6 |
+
"""
|
| 7 |
+
model_size: tiny, base, small, medium, large-v3
|
| 8 |
+
device: cpu or cuda
|
| 9 |
+
compute_type: int8, float16, int8_float16 等
|
| 10 |
+
"""
|
| 11 |
+
self.model_size = model_size
|
| 12 |
+
self.device = device
|
| 13 |
+
self.compute_type = compute_type
|
| 14 |
+
self.model = None
|
| 15 |
+
|
| 16 |
+
def load_model(self):
|
| 17 |
+
"""延迟加载模型,方便在加载前显示提示"""
|
| 18 |
+
if self.model is None:
|
| 19 |
+
# 自动处理 compute_type,如果 GPU 不支持 float16 则回退
|
| 20 |
+
stt_compute_type = self.compute_type
|
| 21 |
+
if self.device == "cpu" and stt_compute_type == "float16":
|
| 22 |
+
stt_compute_type = "int8"
|
| 23 |
+
|
| 24 |
+
self.model = WhisperModel(
|
| 25 |
+
self.model_size,
|
| 26 |
+
device=self.device,
|
| 27 |
+
compute_type=stt_compute_type,
|
| 28 |
+
download_root=os.path.join(os.getcwd(), "models")
|
| 29 |
+
)
|
| 30 |
+
return self.model
|
| 31 |
+
|
| 32 |
+
def transcribe(self, audio_path):
|
| 33 |
+
"""识别音频并返回 segments (生成器) 和 info"""
|
| 34 |
+
model = self.load_model()
|
| 35 |
+
segments, info = model.transcribe(audio_path, beam_size=5)
|
| 36 |
+
return segments, info
|
| 37 |
+
|
| 38 |
+
@staticmethod
|
| 39 |
+
def is_cuda_available():
|
| 40 |
+
"""检测 CUDA 是否可用"""
|
| 41 |
+
try:
|
| 42 |
+
import ctranslate2
|
| 43 |
+
return ctranslate2.get_cuda_device_count() > 0
|
| 44 |
+
except:
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
@staticmethod
|
| 48 |
+
def get_downloaded_models():
|
| 49 |
+
"""获取本地已下载的模型列表"""
|
| 50 |
+
model_dir = os.path.join(os.getcwd(), "models")
|
| 51 |
+
if not os.path.exists(model_dir):
|
| 52 |
+
return []
|
| 53 |
+
|
| 54 |
+
models = []
|
| 55 |
+
for d in os.listdir(model_dir):
|
| 56 |
+
if "faster-whisper-" in d:
|
| 57 |
+
name = d.split("-")[-1]
|
| 58 |
+
models.append(name)
|
| 59 |
+
elif os.path.isdir(os.path.join(model_dir, d)):
|
| 60 |
+
models.append(d)
|
| 61 |
+
|
| 62 |
+
valid_names = ["tiny", "base", "small", "medium", "large-v1", "large-v2", "large-v3"]
|
| 63 |
+
found = [m for m in models if any(v in m for v in valid_names)]
|
| 64 |
+
final_models = []
|
| 65 |
+
for f in found:
|
| 66 |
+
for v in valid_names:
|
| 67 |
+
if v in f:
|
| 68 |
+
final_models.append(v)
|
| 69 |
+
break
|
| 70 |
+
|
| 71 |
+
return sorted(list(set(final_models)))
|
translator.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from openai import OpenAI
|
| 2 |
+
|
| 3 |
+
class Translator:
|
| 4 |
+
def __init__(self, api_key, base_url, model="base"):
|
| 5 |
+
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
| 6 |
+
self.model = model
|
| 7 |
+
|
| 8 |
+
def translate_text(self, text, target_lang="中文"):
|
| 9 |
+
"""翻译单段文本"""
|
| 10 |
+
if not text.strip():
|
| 11 |
+
return ""
|
| 12 |
+
|
| 13 |
+
prompt = f"你是一个专业的视频字幕翻译专家。请将以下字幕内容翻译成{target_lang},保持原意,语言自然、简洁。只返回翻译后的内容,不要有任何解释。\n\n内容:{text}"
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
response = self.client.chat.completions.create(
|
| 17 |
+
model=self.model,
|
| 18 |
+
messages=[{"role": "user", "content": prompt}],
|
| 19 |
+
temperature=0.3
|
| 20 |
+
)
|
| 21 |
+
return response.choices[0].message.content.strip()
|
| 22 |
+
except Exception as e:
|
| 23 |
+
return f"翻译错误: {str(e)}"
|
| 24 |
+
|
| 25 |
+
def translate_segments(self, segments, target_lang="中文"):
|
| 26 |
+
"""批量翻译字幕段 (为了演示,这里使用循环翻译,实际可优化为批量处理)"""
|
| 27 |
+
translated_segments = []
|
| 28 |
+
for seg in segments:
|
| 29 |
+
# 创建一个新的对象来保存翻译后的内容,保持原有的时间戳
|
| 30 |
+
new_seg = type('Segment', (), {
|
| 31 |
+
'start': seg.start,
|
| 32 |
+
'end': seg.end,
|
| 33 |
+
'text': self.translate_text(seg.text, target_lang)
|
| 34 |
+
})
|
| 35 |
+
translated_segments.append(new_seg)
|
| 36 |
+
return translated_segments
|
utils.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import subprocess
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
class VideoUtils:
|
| 6 |
+
@staticmethod
|
| 7 |
+
def prepare_audio(input_path, output_path):
|
| 8 |
+
"""从视频或音频中提取/转换音频为标准格式 (16kHz, mono, wav)"""
|
| 9 |
+
# 使用 wav 格式更通用,不需要 libmp3lame 编码器
|
| 10 |
+
cmd = [
|
| 11 |
+
'ffmpeg', '-y', '-i', input_path,
|
| 12 |
+
'-vn', '-acodec', 'pcm_s16le', '-ar', '16000', '-ac', '1',
|
| 13 |
+
output_path
|
| 14 |
+
]
|
| 15 |
+
try:
|
| 16 |
+
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
| 17 |
+
return result
|
| 18 |
+
except subprocess.CalledProcessError as e:
|
| 19 |
+
error_msg = f"FFmpeg 处理音频失败!\n错误代码: {e.returncode}\n错误输出: {e.stderr}"
|
| 20 |
+
print(error_msg) # 控制台打印
|
| 21 |
+
raise Exception(error_msg)
|
| 22 |
+
except FileNotFoundError:
|
| 23 |
+
raise Exception("找不到 FFmpeg 命令。请确保 FFmpeg 已安装并已添加到系统环境变量 PATH 中。")
|
| 24 |
+
|
| 25 |
+
@staticmethod
|
| 26 |
+
def embed_subtitles(video_path, srt_path, output_path):
|
| 27 |
+
"""将字幕嵌入视频 (硬压)"""
|
| 28 |
+
# 注意:Windows下路径处理较复杂,ffmpeg 的 subtitles 滤镜需要特殊转义
|
| 29 |
+
abs_srt_path = os.path.abspath(srt_path).replace('\\', '/').replace(':', '\\:')
|
| 30 |
+
cmd = [
|
| 31 |
+
'ffmpeg', '-y', '-i', video_path,
|
| 32 |
+
'-vf', f"subtitles='{abs_srt_path}'",
|
| 33 |
+
'-c:a', 'copy',
|
| 34 |
+
output_path
|
| 35 |
+
]
|
| 36 |
+
try:
|
| 37 |
+
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
| 38 |
+
return result
|
| 39 |
+
except subprocess.CalledProcessError as e:
|
| 40 |
+
error_msg = f"FFmpeg 合成视频失败!\n错误代码: {e.returncode}\n错误输出: {e.stderr}"
|
| 41 |
+
print(error_msg)
|
| 42 |
+
raise Exception(error_msg)
|
| 43 |
+
except FileNotFoundError:
|
| 44 |
+
raise Exception("找不到 FFmpeg 命令。")
|
| 45 |
+
|
| 46 |
+
@staticmethod
|
| 47 |
+
def format_timestamp(seconds: float):
|
| 48 |
+
"""将秒转换为 SRT 时间格式 00:00:00,000"""
|
| 49 |
+
td_hours = int(seconds // 3600)
|
| 50 |
+
td_mins = int((seconds % 3600) // 60)
|
| 51 |
+
td_secs = int(seconds % 60)
|
| 52 |
+
td_msecs = int((seconds - int(seconds)) * 1000)
|
| 53 |
+
return f"{td_hours:02}:{td_mins:02}:{td_secs:02},{td_msecs:03}"
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
def write_srt(segments, output_path):
|
| 57 |
+
"""生成 SRT 格式文件"""
|
| 58 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 59 |
+
for i, segment in enumerate(segments, 1):
|
| 60 |
+
start = VideoUtils.format_timestamp(segment.start)
|
| 61 |
+
end = VideoUtils.format_timestamp(segment.end)
|
| 62 |
+
f.write(f"{i}\n{start} --> {end}\n{segment.text.strip()}\n\n")
|
| 63 |
+
|
| 64 |
+
@staticmethod
|
| 65 |
+
def parse_srt(srt_content):
|
| 66 |
+
"""简单的 SRT 解析器,返回 segments 列表"""
|
| 67 |
+
import re
|
| 68 |
+
segments = []
|
| 69 |
+
# 正则匹配 SRT 块
|
| 70 |
+
pattern = re.compile(r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\n(.*?)(?=\n\n|\n$|$)', re.DOTALL)
|
| 71 |
+
|
| 72 |
+
def time_to_seconds(t_str):
|
| 73 |
+
h, m, s_ms = t_str.split(':')
|
| 74 |
+
s, ms = s_ms.split(',')
|
| 75 |
+
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
|
| 76 |
+
|
| 77 |
+
matches = pattern.findall(srt_content)
|
| 78 |
+
for m in matches:
|
| 79 |
+
idx, start_t, end_t, text = m
|
| 80 |
+
seg = type('Segment', (), {
|
| 81 |
+
'start': time_to_seconds(start_t),
|
| 82 |
+
'end': time_to_seconds(end_t),
|
| 83 |
+
'text': text.strip()
|
| 84 |
+
})
|
| 85 |
+
segments.append(seg)
|
| 86 |
+
return segments
|