diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df67e1a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Exclude common Python files and directories +venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.pyz +*.pyw +*.pyi +*.egg-info/ + +# Exclude development and local files +.env +.env.* +*.log +*.db + +# Exclude version control system files +.git/ +.gitignore +.svn/ + +storage/ +config.toml diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4216326 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,74 @@ +# Use an official Python runtime as a parent image +FROM python:3.11-slim-bullseye + +# Set the working directory in the container +WORKDIR /MoneyPrinterTurbo + +# 设置/MoneyPrinterTurbo目录权限为777 +RUN chmod 777 /MoneyPrinterTurbo + +ENV PYTHONPATH="/MoneyPrinterTurbo" + +# Install system dependencies with domestic mirrors first for stability +RUN echo "deb http://mirrors.aliyun.com/debian bullseye main" > /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian-security bullseye-security main" >> /etc/apt/sources.list && \ + ( \ + for i in 1 2 3; do \ + echo "Attempt $i: Using Aliyun mirror"; \ + apt-get update && apt-get install -y --no-install-recommends \ + git \ + imagemagick \ + ffmpeg && break || \ + echo "Attempt $i failed, retrying..."; \ + if [ $i -eq 3 ]; then \ + echo "Aliyun mirror failed, switching to Tsinghua mirror"; \ + sed -i 's/mirrors.aliyun.com/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list && \ + sed -i 's/mirrors.aliyun.com\/debian-security/mirrors.tuna.tsinghua.edu.cn\/debian-security/g' /etc/apt/sources.list && \ + ( \ + apt-get update && apt-get install -y --no-install-recommends \ + git \ + imagemagick \ + ffmpeg || \ + ( \ + echo "Tsinghua mirror failed, switching to default Debian mirror"; \ + sed -i 's/mirrors.tuna.tsinghua.edu.cn/deb.debian.org/g' /etc/apt/sources.list && \ + sed -i 's/mirrors.tuna.tsinghua.edu.cn\/debian-security/security.debian.org/g' /etc/apt/sources.list; \ + apt-get update && apt-get install -y --no-install-recommends \ + git \ + imagemagick \ + ffmpeg; \ + ); \ + ); \ + fi; \ + sleep 5; \ + done \ + ) && rm -rf /var/lib/apt/lists/* + +# Fix security policy for ImageMagick +RUN sed -i '/ +

MoneyPrinterTurbo 💸

+ +

+ Stargazers + Issues + Forks + License +

+ +

English | 简体中文

+ +
+ harry0703%2FMoneyPrinterTurbo | Trendshift +
+ +Simply provide a topic or keyword for a video, and it will automatically generate the video copy, video +materials, video subtitles, and video background music before synthesizing a high-definition short video. + +### WebUI + +![](docs/webui-en.jpg) + +### API Interface + +![](docs/api.jpg) + + + +## Features 🎯 + +- [x] Complete **MVC architecture**, **clearly structured** code, easy to maintain, supports both `API` + and `Web interface` +- [x] Supports **AI-generated** video copy, as well as **customized copy** +- [x] Supports various **high-definition video** sizes + - [x] Portrait 9:16, `1080x1920` + - [x] Landscape 16:9, `1920x1080` +- [x] Supports **batch video generation**, allowing the creation of multiple videos at once, then selecting the most + satisfactory one +- [x] Supports setting the **duration of video clips**, facilitating adjustments to material switching frequency +- [x] Supports video copy in both **Chinese** and **English** +- [x] Supports **multiple voice** synthesis, with **real-time preview** of effects +- [x] Supports **subtitle generation**, with adjustable `font`, `position`, `color`, `size`, and also + supports `subtitle outlining` +- [x] Supports **background music**, either random or specified music files, with adjustable `background music volume` +- [x] Video material sources are **high-definition** and **royalty-free**, and you can also use your own **local materials** +- [x] Supports integration with various models such as **OpenAI**, **Moonshot**, **Azure**, **gpt4free**, **one-api**, **Qwen**, **Google Gemini**, **Ollama**, **DeepSeek**, **MiniMax**, **ERNIE**, **Pollinations**, **ModelScope** and more + +## Video Demos 📺 + +### Portrait 9:16 + + + + + + + + + + + + + + +
▶️ How to Add Fun to Your Life ▶️ What is the Meaning of Life
+ +### Landscape 16:9 + + + + + + + + + + + + + + +
▶️ What is the Meaning of Life▶️ Why Exercise
+ +## System Requirements 📦 + +- Recommended platforms: Windows 10+, macOS 11+, or a mainstream Linux distribution +- A GPU is not required, but it is recommended if you want faster local transcription, faster video processing, or smoother batch generation + +| Item | Minimum | Recommended | Optimal | +| --- | --- | --- | --- | +| CPU | 4 cores | 6 to 8 cores | 8+ cores | +| RAM | 4 GB | 8 GB | 16+ GB | +| GPU | Not required | 4+ GB VRAM | 8+ GB VRAM | + +- If you mainly rely on cloud LLMs, cloud TTS, and online material sources, CPU and RAM matter more than GPU +- If you use `faster-whisper`, batch generation, or heavier local processing, a GPU will improve throughput noticeably + +## Quick Start 🚀 + +### Recommended Paths + +- Windows users: use the one-click package first for the fastest local trial +- MacOS / Linux users: use `uv sync --frozen` for the primary local setup path +- If you want a more isolated runtime: use Docker deployment + +### Run in Google Colab +Want to try MoneyPrinterTurbo without setting up a local environment? Run it directly in Google Colab! + +[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/harry0703/MoneyPrinterTurbo/blob/main/docs/MoneyPrinterTurbo.ipynb) + + +### Windows + +The downloadable package is still the older `v1.2.6` bundled build. After downloading, run `update.bat` first to bring it up to the latest code. + +Google Drive (v1.2.6): https://drive.google.com/file/d/1HsbzfT7XunkrCrHw5ncUjFX8XX4zAuUh/view?usp=sharing + +After downloading, it is recommended to **double-click** `update.bat` first to update to the **latest code**, then double-click `start.bat` to launch + +After launching, the browser will open automatically (if it opens blank, it is recommended to use **Chrome** or **Edge**) + +### Other Systems + +One-click startup packages have not been created yet. See the **Installation & Deployment** section below. It is recommended to use **docker** for deployment, which is more convenient. + +## Installation & Deployment 📥 + +### Prerequisites + +#### ① Clone the Project + +```shell +git clone https://github.com/harry0703/MoneyPrinterTurbo.git +``` + +#### ② Modify the Configuration File + +- Copy the `config.example.toml` file and rename it to `config.toml` +- Follow the instructions in the `config.toml` file to configure `pexels_api_keys` and `llm_provider`, and according to + the llm_provider's service provider, set up the corresponding API Key + +### Docker Deployment 🐳 + +#### ① Launch the Docker Container + +If you haven't installed Docker, please install it first https://www.docker.com/products/docker-desktop/ +If you are using a Windows system, please refer to Microsoft's documentation: + +1. https://learn.microsoft.com/en-us/windows/wsl/install +2. https://learn.microsoft.com/en-us/windows/wsl/tutorials/wsl-containers + +```shell +cd MoneyPrinterTurbo +docker-compose up +``` + +> Note:The latest version of docker will automatically install docker compose in the form of a plug-in, and the start command is adjusted to `docker compose up ` + +#### ② Access the Web Interface + +Open your browser and visit http://127.0.0.1:8501 + +#### ③ Access the API Interface + +Open your browser and visit http://0.0.0.0:8080/docs Or http://0.0.0.0:8080/redoc + +### Manual Deployment 📦 + +#### ① Create a Python Virtual Environment + +It is recommended to use [uv](https://docs.astral.sh/uv/) to manage the Python environment and dependencies, with Python `3.11` as the default runtime. + +```shell +git clone https://github.com/harry0703/MoneyPrinterTurbo.git +cd MoneyPrinterTurbo +uv python install 3.11 +uv sync --frozen +``` + +If you are not using `uv` yet, you can still use `venv + pip`. + +```shell +python3.11 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +Notes: +- `pyproject.toml` is now the primary dependency manifest. +- `uv.lock` pins the resolved environment, so `uv sync --frozen` is recommended by default. +- `requirements.txt` is kept only for legacy `pip`-based installation. + +#### ② Install ImageMagick + +###### Windows: + +- Download https://imagemagick.org/script/download.php Choose the Windows version, make sure to select the **static library** version, such as ImageMagick-7.1.1-32-Q16-x64-**static**.exe +- Install the downloaded ImageMagick, **do not change the installation path** +- Modify the `config.toml` configuration file, set `imagemagick_path` to your actual installation path + +###### MacOS: + +```shell +brew install imagemagick +``` + +###### Ubuntu + +```shell +sudo apt-get install imagemagick +``` + +###### CentOS + +```shell +sudo yum install ImageMagick +``` + +#### ③ Launch the Web Interface 🌐 + +Note that you need to execute the following commands in the `root directory` of the MoneyPrinterTurbo project + +###### Windows + +```powershell +.\webui.bat +``` + +You can also run `webui.bat` in CMD. +`webui.bat` prefers the project `.venv` or bundled Python from the portable package. If no project Python is found but `uv` is installed, it automatically falls back to `uv run streamlit`. +To allow other devices on your LAN to access the WebUI, run `set MPT_WEBUI_HOST=0.0.0.0` before running `webui.bat`. + +###### MacOS or Linux + +```shell +uv run streamlit run ./webui/Main.py --browser.gatherUsageStats=False +``` + +If you have already activated the virtual environment manually, you can still run: + +```shell +sh webui.sh +``` + +After launching, the browser will open automatically + +#### ④ Launch the API Service 🚀 + +```shell +uv run python main.py +``` + +If you have already activated the virtual environment manually, you can still run: + +```shell +python main.py +``` + +## Special Thanks 🙏 + +Due to the **deployment** and **usage** of this project, there is a certain threshold for some beginner users. We would +like to express our special thanks to + +**RecCloud (AI-Powered Multimedia Service Platform)** for providing a free `AI Video Generator` service based on this +project. It allows for online use without deployment, which is very convenient. + +- Chinese version: https://reccloud.cn +- English version: https://reccloud.com + +![](docs/reccloud.com.jpg) + +## Thanks for Sponsorship 🙏 + +Thanks to Picwish https://picwish.com for supporting and sponsoring this project, enabling continuous updates and maintenance. + +Picwish focuses on the **image processing field**, providing a rich set of **image processing tools** that extremely simplify complex operations, truly making image processing easier. + +![picwish.jpg](docs/picwish.com.jpg) + +After launching, you can view the `API documentation` at http://127.0.0.1:8080/docs and directly test the interface +online for a quick experience. + +## Voice Synthesis 🗣 + +A list of all supported voices can be viewed here: [Voice List](./docs/voice-list.txt) + +2024-04-16 v1.1.2 Added 9 new Azure voice synthesis voices that require API KEY configuration. These voices sound more realistic. + +## Subtitle Generation 📜 + +Currently, there are 2 ways to generate subtitles: + +- **edge**: Faster generation speed, better performance, no specific requirements for computer configuration, but the + quality may be unstable +- **whisper**: Slower generation speed, poorer performance, specific requirements for computer configuration, but more + reliable quality + +You can switch between them by modifying the `subtitle_provider` in the `config.toml` configuration file + +It is recommended to use `edge` mode, and switch to `whisper` mode if the quality of the subtitles generated is not +satisfactory. + +> Note: +> +> 1. In whisper mode, you need to download a model file from HuggingFace, about 3GB in size, please ensure good internet connectivity +> 2. If left blank, it means no subtitles will be generated. + +> Since HuggingFace is not accessible in China, you can use the following methods to download the `whisper-large-v3` model file + +Download links: + +- Baidu Netdisk: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9 +- Quark Netdisk: https://pan.quark.cn/s/3ee3d991d64b + +After downloading the model, extract it and place the entire directory in `.\MoneyPrinterTurbo\models`, +The final file path should look like this: `.\MoneyPrinterTurbo\models\whisper-large-v3` + +``` +MoneyPrinterTurbo + ├─models + │ └─whisper-large-v3 + │ config.json + │ model.bin + │ preprocessor_config.json + │ tokenizer.json + │ vocabulary.json +``` + +## Background Music 🎵 + +Background music for videos is located in the project's `resource/songs` directory. +> The current project includes some default music from YouTube videos. If there are copyright issues, please delete +> them. + +## Subtitle Fonts 🅰 + +Fonts for rendering video subtitles are located in the project's `resource/fonts` directory, and you can also add your +own fonts. + +## Common Questions 🤔 + +### ❓RuntimeError: No ffmpeg exe could be found + +Normally, ffmpeg will be automatically downloaded and detected. +However, if your environment has issues preventing automatic downloads, you may encounter the following error: + +``` +RuntimeError: No ffmpeg exe could be found. +Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. +``` + +In this case, you can download ffmpeg from https://www.gyan.dev/ffmpeg/builds/, unzip it, and set `ffmpeg_path` to your +actual installation path. + +```toml +[app] +# Please set according to your actual path, note that Windows path separators are \\ +ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" +``` + +### ❓ImageMagick is not installed on your computer + +[issue 33](https://github.com/harry0703/MoneyPrinterTurbo/issues/33) + +1. Follow the `example configuration` provided `download address` to + install https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-30-Q16-x64-static.exe, using the static library +2. Do not install in a path with Chinese characters to avoid unpredictable issues + +[issue 54](https://github.com/harry0703/MoneyPrinterTurbo/issues/54#issuecomment-2017842022) + +For Linux systems, you can manually install it, refer to https://cn.linux-console.net/?p=16978 + +Thanks to [@wangwenqiao666](https://github.com/wangwenqiao666) for their research and exploration + +### ❓ImageMagick's security policy prevents operations related to temporary file @/tmp/tmpur5hyyto.txt + +You can find these policies in ImageMagick's configuration file policy.xml. +This file is usually located in /etc/ImageMagick-`X`/ or a similar location in the ImageMagick installation directory. +Modify the entry containing `pattern="@"`, change `rights="none"` to `rights="read|write"` to allow read and write operations on files. + +### ❓OSError: [Errno 24] Too many open files + +This issue is caused by the system's limit on the number of open files. You can solve it by modifying the system's file open limit. + +Check the current limit: + +```shell +ulimit -n +``` + +If it's too low, you can increase it, for example: + +```shell +ulimit -n 10240 +``` + +### ❓Whisper model download failed, with the following error + +LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and +outgoing trafic has been disabled. +To enablerepo look-ups and downloads online, pass 'local files only=False' as input. + +or + +An error occurred while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub: +An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the +specified revision on the local disk. Please check your internet connection and try again. +Trying to load the model directly from the local cache, if it exists. + +Solution: [Click to see how to manually download the model from netdisk](#subtitle-generation-) + +## Feedback & Suggestions 📢 + +- You can submit an [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) or + a [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls). + +## License 📝 + +Click to view the [`LICENSE`](LICENSE) file + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) diff --git a/README.md b/README.md new file mode 100644 index 0000000..614ebd6 --- /dev/null +++ b/README.md @@ -0,0 +1,402 @@ +
+

MoneyPrinterTurbo 💸

+ +

+ Stargazers + Issues + Forks + License +

+
+

简体中文 | English

+
+ harry0703%2FMoneyPrinterTurbo | Trendshift +
+
+只需提供一个视频 主题关键词 ,就可以全自动生成视频文案、视频素材、视频字幕、视频背景音乐,然后合成一个高清的短视频。 +
+ +

Web界面

+ +![](docs/webui.jpg) + +

API界面

+ +![](docs/api.jpg) + +
+ +## 功能特性 🎯 + +- [x] 完整的 **MVC架构**,代码 **结构清晰**,易于维护,支持 `API` 和 `Web界面` +- [x] 支持视频文案 **AI自动生成**,也可以**自定义文案** +- [x] 支持多种 **高清视频** 尺寸 + - [x] 竖屏 9:16,`1080x1920` + - [x] 横屏 16:9,`1920x1080` +- [x] 支持 **批量视频生成**,可以一次生成多个视频,然后选择一个最满意的 +- [x] 支持 **视频片段时长** 设置,方便调节素材切换频率 +- [x] 支持 **中文** 和 **英文** 视频文案 +- [x] 支持 **多种语音** 合成,可 **实时试听** 效果 +- [x] 支持 **字幕生成**,可以调整 `字体`、`位置`、`颜色`、`大小`,同时支持`字幕描边`设置 +- [x] 支持 **背景音乐**,随机或者指定音乐文件,可设置`背景音乐音量` +- [x] 视频素材来源 **高清**,而且 **无版权**,也可以使用自己的 **本地素材** +- [x] 支持 **OpenAI**、**Moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问**、**Google Gemini**、**Ollama**、**DeepSeek**、**MiniMax**、 **文心一言**, **Pollinations**、**ModelScope** 等多种模型接入 + - 中国用户建议使用 **DeepSeek** 或 **Moonshot** 作为大模型提供商(国内可直接访问,不需要VPN。注册就送额度,基本够用) + +## 视频演示 📺 + +### 竖屏 9:16 + + + + + + + + + + + + + + + + +
▶️ 《如何增加生活的乐趣》▶️ 《金钱的作用》
更真实的合成声音
▶️ 《生命的意义是什么》
+ +### 横屏 16:9 + + + + + + + + + + + + + + +
▶️《生命的意义是什么》▶️《为什么要运动》
+ +## 配置要求 📦 + +- 建议系统:Windows 10 或 MacOS 11.0 以上,或主流 Linux 发行版 +- GPU 不是必需项,但如果你希望本地转录、更快的视频处理或更顺畅的批量生成体验,建议使用带显存的独立显卡 + +| 项目 | 最低配置 | 推荐配置 | 理想配置 | +| --- | --- | --- | --- | +| CPU | 4 核 | 6 到 8 核 | 8 核及以上 | +| RAM | 4 GB | 8 GB | 16 GB 及以上 | +| GPU | 非必须 | 4 GB 显存及以上 | 8 GB 显存及以上 | + +- 如果你主要依赖云端 LLM、云端 TTS 和在线素材源,CPU 与内存比 GPU 更重要 +- 如果你启用 `faster-whisper`、批量生成或更重的本地处理链路,GPU 会明显提升速度 + + +## 快速开始 🚀 + +### 推荐使用方式 + +- Windows 用户:优先使用一键启动包,适合快速体验 +- MacOS / Linux 用户:优先使用 `uv sync --frozen` 进行本地部署 +- 想要隔离运行环境:优先使用 Docker 部署 + +### 在 Google Colab 中运行 +免去本地环境配置,点击直接在 Google Colab 中快速体验 MoneyPrinterTurbo + +[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/harry0703/MoneyPrinterTurbo/blob/main/docs/MoneyPrinterTurbo.ipynb) + + +### Windows一键启动包 + +下载一键启动包,解压直接使用(路径不要有 **中文**、**特殊字符**、**空格**) +当前提供的安装包仍是 `v1.2.6` 的旧打包版本,建议下载后先执行 `update.bat` 更新到最新代码。 + +- 百度网盘(v1.2.6): https://pan.baidu.com/s/1wg0UaIyXpO3SqIpaq790SQ?pwd=sbqx 提取码: sbqx +- Google Drive (v1.2.6): https://drive.google.com/file/d/1HsbzfT7XunkrCrHw5ncUjFX8XX4zAuUh/view?usp=sharing + +下载后,建议先**双击执行** `update.bat` 更新到**最新代码**,然后双击 `start.bat` 启动 + +启动后,会自动打开浏览器(如果打开是空白,建议换成 **Chrome** 或者 **Edge** 打开) + +## 安装部署 📥 + +### 前提条件 + +- 尽量不要使用 **中文路径**,避免出现一些无法预料的问题 +- 请确保你的 **网络** 是正常的,VPN需要打开`全局流量`模式 + +#### ① 克隆代码 + +```shell +git clone https://github.com/harry0703/MoneyPrinterTurbo.git +``` + +#### ② 修改配置文件(可选,建议启动后也可以在 WebUI 里面配置) + +- 将 `config.example.toml` 文件复制一份,命名为 `config.toml` +- 按照 `config.toml` 文件中的说明,配置好 `pexels_api_keys` 和 `llm_provider`,并根据 llm_provider 对应的服务商,配置相关的 + API Key + +### Docker部署 🐳 + +#### ① 启动Docker + +如果未安装 Docker,请先安装 https://www.docker.com/products/docker-desktop/ + +如果是Windows系统,请参考微软的文档: + +1. https://learn.microsoft.com/zh-cn/windows/wsl/install +2. https://learn.microsoft.com/zh-cn/windows/wsl/tutorials/wsl-containers + +```shell +cd MoneyPrinterTurbo +docker-compose up +``` + +> 注意:最新版的docker安装时会自动以插件的形式安装docker compose,启动命令调整为docker compose up + +#### ② 访问Web界面 + +打开浏览器,访问 http://127.0.0.1:8501 + +#### ③ 访问API文档 + +打开浏览器,访问 http://0.0.0.0:8080/docs 或者 http://0.0.0.0:8080/redoc + +### 手动部署 📦 + +> 视频教程 + +- 完整的使用演示:https://v.douyin.com/iFhnwsKY/ +- 如何在Windows上部署:https://v.douyin.com/iFyjoW3M + +#### ① 创建虚拟环境 + +推荐使用 [uv](https://docs.astral.sh/uv/) 管理 Python 环境和依赖,默认使用 Python `3.11` + +```shell +git clone https://github.com/harry0703/MoneyPrinterTurbo.git +cd MoneyPrinterTurbo +uv python install 3.11 +uv sync --frozen +``` + +如果你暂时不使用 `uv`,也可以继续使用 `venv + pip` + +```shell +python3.11 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +说明: +- `pyproject.toml` 是主依赖定义文件 +- `uv.lock` 是锁文件,建议默认执行 `uv sync --frozen` +- `requirements.txt` 仅保留给旧的 `pip` 安装方式兼容使用 + +#### ② 安装好 ImageMagick + +- Windows: + - 下载 https://imagemagick.org/script/download.php 选择Windows版本,切记一定要选择 **静态库** 版本,比如 + ImageMagick-7.1.1-32-Q16-x64-**static**.exe + - 安装下载好的 ImageMagick,**注意不要修改安装路径** + - 修改 `配置文件 config.toml` 中的 `imagemagick_path` 为你的 **实际安装路径** + +- MacOS: + ```shell + brew install imagemagick + ```` +- Ubuntu + ```shell + sudo apt-get install imagemagick + ``` +- CentOS + ```shell + sudo yum install ImageMagick + ``` + +#### ③ 启动Web界面 🌐 + +注意需要到 MoneyPrinterTurbo 项目 `根目录` 下执行以下命令 + +###### Windows + +```powershell +.\webui.bat +``` + +在 CMD 中也可以执行 `webui.bat`。 +`webui.bat` 会优先使用项目 `.venv` 或一键包内置 Python;如果没有找到项目 Python,但已安装 `uv`,会自动切换为 `uv run streamlit`。 +如需允许局域网内其他设备访问 WebUI,可以先执行 `set MPT_WEBUI_HOST=0.0.0.0`,再运行 `webui.bat`。 + +###### MacOS or Linux + +```shell +uv run streamlit run ./webui/Main.py --browser.gatherUsageStats=False +``` + +如果你已经手动激活了虚拟环境,也可以直接执行: + +```shell +sh webui.sh +``` + +启动后,会自动打开浏览器(如果打开是空白,建议换成 **Chrome** 或者 **Edge** 打开) + +#### ④ 启动API服务 🚀 + +```shell +uv run python main.py +``` + +如果你已经手动激活了虚拟环境,也可以直接执行: + +```shell +python main.py +``` + +## 特别感谢 🙏 + +由于该项目的 **部署** 和 **使用**,对于一些小白用户来说,还是 **有一定的门槛**,在此特别感谢 +**录咖(AI智能 多媒体服务平台)** 网站基于该项目,提供的免费`AI视频生成器`服务,可以不用部署,直接在线使用,非常方便。 + +- 中文版:https://reccloud.cn +- 英文版:https://reccloud.com + +![](docs/reccloud.cn.jpg) + +## 感谢赞助 🙏 + +感谢佐糖 https://picwish.cn 对该项目的支持和赞助,使得该项目能够持续的更新和维护。 + +佐糖专注于**图像处理领域**,提供丰富的**图像处理工具**,将复杂操作极致简化,真正实现让图像处理更简单。 + +![picwish.jpg](docs/picwish.jpg) + +启动后,可以查看 `API文档` http://127.0.0.1:8080/docs 或者 http://127.0.0.1:8080/redoc 直接在线调试接口,快速体验。 + +## 语音合成 🗣 + +所有支持的声音列表,可以查看:[声音列表](./docs/voice-list.txt) + +2024-04-16 v1.1.2 新增了9种Azure的语音合成声音,需要配置API KEY,该声音合成的更加真实。 + +## 字幕生成 📜 + +当前支持2种字幕生成方式: + +- **edge**: 生成`速度快`,性能更好,对电脑配置没有要求,但是质量可能不稳定 +- **whisper**: 生成`速度慢`,性能较差,对电脑配置有一定要求,但是`质量更可靠`。 + +可以修改 `config.toml` 配置文件中的 `subtitle_provider` 进行切换 + +建议使用 `edge` 模式,如果生成的字幕质量不好,再切换到 `whisper` 模式 + +> 注意: + +1. whisper 模式下需要到 HuggingFace 下载一个模型文件,大约 3GB 左右,请确保网络通畅 +2. 如果留空,表示不生成字幕。 + +> 由于国内无法访问 HuggingFace,可以使用以下方法下载 `whisper-large-v3` 的模型文件 + +下载地址: + +- 百度网盘: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9 +- 夸克网盘:https://pan.quark.cn/s/3ee3d991d64b + +模型下载后解压,整个目录放到 `.\MoneyPrinterTurbo\models` 里面, +最终的文件路径应该是这样: `.\MoneyPrinterTurbo\models\whisper-large-v3` + +``` +MoneyPrinterTurbo + ├─models + │ └─whisper-large-v3 + │ config.json + │ model.bin + │ preprocessor_config.json + │ tokenizer.json + │ vocabulary.json +``` + +## 背景音乐 🎵 + +用于视频的背景音乐,位于项目的 `resource/songs` 目录下。 +> 当前项目里面放了一些默认的音乐,来自于 YouTube 视频,如有侵权,请删除。 + +## 字幕字体 🅰 + +用于视频字幕的渲染,位于项目的 `resource/fonts` 目录下,你也可以放进去自己的字体。 + +## 常见问题 🤔 + +### ❓RuntimeError: No ffmpeg exe could be found + +通常情况下,ffmpeg 会被自动下载,并且会被自动检测到。 +但是如果你的环境有问题,无法自动下载,可能会遇到如下错误: + +``` +RuntimeError: No ffmpeg exe could be found. +Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. +``` + +此时你可以从 https://www.gyan.dev/ffmpeg/builds/ 下载ffmpeg,解压后,设置 `ffmpeg_path` 为你的实际安装路径即可。 + +```toml +[app] +# 请根据你的实际路径设置,注意 Windows 路径分隔符为 \\ +ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" +``` + +### ❓ImageMagick的安全策略阻止了与临时文件@/tmp/tmpur5hyyto.txt相关的操作 + +可以在ImageMagick的配置文件policy.xml中找到这些策略。 +这个文件通常位于 /etc/ImageMagick-`X`/ 或 ImageMagick 安装目录的类似位置。 +修改包含`pattern="@"`的条目,将`rights="none"`更改为`rights="read|write"`以允许对文件的读写操作。 + +### ❓OSError: [Errno 24] Too many open files + +这个问题是由于系统打开文件数限制导致的,可以通过修改系统的文件打开数限制来解决。 + +查看当前限制 + +```shell +ulimit -n +``` + +如果过低,可以调高一些,比如 + +```shell +ulimit -n 10240 +``` + +### ❓Whisper 模型下载失败,出现如下错误 + +LocalEntryNotfoundEror: Cannot find an appropriate cached snapshotfolderfor the specified revision on the local disk and +outgoing trafic has been disabled. +To enablerepo look-ups and downloads online, pass 'local files only=False' as input. + +或者 + +An error occurred while synchronizing the model Systran/faster-whisper-large-v3 from the Hugging Face Hub: +An error happened while trying to locate the files on the Hub and we cannot find the appropriate snapshot folder for the +specified revision on the local disk. Please check your internet connection and try again. +Trying to load the model directly from the local cache, if it exists. + +解决方法:[点击查看如何从网盘手动下载模型](#%E5%AD%97%E5%B9%95%E7%94%9F%E6%88%90-) + +## 反馈建议 📢 + +- 可以提交 [issue](https://github.com/harry0703/MoneyPrinterTurbo/issues) + 或者 [pull request](https://github.com/harry0703/MoneyPrinterTurbo/pulls)。 + +## 许可证 📝 + +点击查看 [`LICENSE`](LICENSE) 文件 + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=harry0703/MoneyPrinterTurbo&type=Date)](https://star-history.com/#harry0703/MoneyPrinterTurbo&Date) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/asgi.py b/app/asgi.py new file mode 100644 index 0000000..01f8b7d --- /dev/null +++ b/app/asgi.py @@ -0,0 +1,82 @@ +"""Application implementation - ASGI.""" + +import os + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +from loguru import logger + +from app.config import config +from app.models.exception import HttpException +from app.router import root_api_router +from app.utils import utils + + +def exception_handler(request: Request, e: HttpException): + return JSONResponse( + status_code=e.status_code, + content=utils.get_response(e.status_code, e.data, e.message), + ) + + +def validation_exception_handler(request: Request, e: RequestValidationError): + return JSONResponse( + status_code=400, + content=utils.get_response( + status=400, data=e.errors(), message="field required" + ), + ) + + +def get_application() -> FastAPI: + """Initialize FastAPI application. + + Returns: + FastAPI: Application object instance. + + """ + instance = FastAPI( + title=config.project_name, + description=config.project_description, + version=config.project_version, + debug=False, + ) + instance.include_router(root_api_router) + instance.add_exception_handler(HttpException, exception_handler) + instance.add_exception_handler(RequestValidationError, validation_exception_handler) + return instance + + +app = get_application() + +# Configures the CORS middleware for the FastAPI app +cors_allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "") +origins = cors_allowed_origins_str.split(",") if cors_allowed_origins_str else ["*"] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +task_dir = utils.task_dir() +app.mount( + "/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name="" +) + +public_dir = utils.public_dir() +app.mount("/", StaticFiles(directory=public_dir, html=True), name="") + + +@app.on_event("shutdown") +def shutdown_event(): + logger.info("shutdown event") + + +@app.on_event("startup") +def startup_event(): + logger.info("startup event") diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..dd46812 --- /dev/null +++ b/app/config/__init__.py @@ -0,0 +1,56 @@ +import os +import sys + +from loguru import logger + +from app.config import config +from app.utils import utils + + +def __init_logger(): + # _log_file = utils.storage_dir("logs/server.log") + _lvl = config.log_level + root_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + ) + + def format_record(record): + # 获取日志记录中的文件全路径 + file_path = record["file"].path + # 将绝对路径转换为相对于项目根目录的路径 + relative_path = os.path.relpath(file_path, root_dir) + # 更新记录中的文件路径 + record["file"].path = f"./{relative_path}" + # 返回修改后的格式字符串 + # 您可以根据需要调整这里的格式 + _format = ( + "{time:%Y-%m-%d %H:%M:%S} | " + + "{level} | " + + '"{file.path}:{line}": {function} ' + + "- {message}" + + "\n" + ) + return _format + + logger.remove() + + logger.add( + sys.stdout, + level=_lvl, + format=format_record, + colorize=True, + ) + + # logger.add( + # _log_file, + # level=_lvl, + # format=format_record, + # rotation="00:00", + # retention="3 days", + # backtrace=True, + # diagnose=True, + # enqueue=True, + # ) + + +__init_logger() diff --git a/app/config/config.py b/app/config/config.py new file mode 100644 index 0000000..fb76600 --- /dev/null +++ b/app/config/config.py @@ -0,0 +1,198 @@ +import os +import shutil +import socket + +import toml +from loguru import logger + +root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) +config_file = f"{root_dir}/config.toml" +_CONTAINER_CGROUP_MARKERS = ("docker", "containerd", "kubepods", "libpod", "podman") +_DOCKER_HOST_GATEWAY_NAME = "host.docker.internal" + + +def is_running_in_container( + dockerenv_path: str = "/.dockerenv", + containerenv_path: str = "/run/.containerenv", + cgroup_path: str = "/proc/1/cgroup", +) -> bool: + """ + 判断当前进程是否运行在容器内。 + + 这个判断主要用于 Ollama 默认地址选择: + - 普通本机运行时,`localhost` 指向用户机器本身; + - Docker 容器内,`localhost` 指向容器自己,访问宿主机 Ollama + 通常需要使用 `host.docker.internal`。 + + 不能只判断 `/proc/1/cgroup` 是否存在,因为普通 Linux 也会有这个文件。 + 这里只在检测到明确的容器标记时返回 True,避免误伤非 Docker Linux 用户。 + 参数保留为可注入路径,便于单元测试覆盖不同运行环境。 + """ + if os.path.isfile(dockerenv_path) or os.path.isfile(containerenv_path): + return True + + try: + with open(cgroup_path, mode="r", encoding="utf-8") as fp: + cgroup_content = fp.read().lower() + except OSError: + return False + + return any(marker in cgroup_content for marker in _CONTAINER_CGROUP_MARKERS) + + +def _can_resolve_hostname(hostname: str) -> bool: + try: + socket.gethostbyname(hostname) + except OSError: + return False + return True + + +def _decode_linux_route_gateway(hex_gateway: str) -> str: + # /proc/net/route 里的 Gateway 是 16 进制小端序,例如 010011AC 表示 + # 172.17.0.1。这里单独解析,是为了在原生 Linux Docker 没有 + # host.docker.internal DNS 记录时,还能尝试访问容器默认网关上的宿主机。 + if len(hex_gateway) != 8: + raise ValueError("invalid gateway length") + + octets = [ + str(int(hex_gateway[index : index + 2], 16)) + for index in range(6, -1, -2) + ] + return ".".join(octets) + + +def get_container_default_gateway_ip(route_path: str = "/proc/net/route") -> str: + """ + 读取 Linux 容器里的默认网关 IP。 + + Docker Desktop 通常提供 `host.docker.internal`,但原生 Linux Docker + 默认不一定提供这个 DNS 名称。默认网关通常可以作为访问宿主机服务的 + 兜底地址;如果用户的 Ollama 只监听 127.0.0.1,则仍需要用户让 + Ollama 监听宿主机网卡或手动配置 `ollama_base_url`。 + """ + try: + with open(route_path, mode="r", encoding="utf-8") as fp: + route_lines = fp.readlines() + except OSError: + return "" + + for line in route_lines[1:]: + fields = line.strip().split() + if len(fields) < 3: + continue + + destination = fields[1] + gateway = fields[2] + if destination != "00000000" or gateway == "00000000": + continue + + try: + return _decode_linux_route_gateway(gateway) + except ValueError: + logger.warning(f"invalid container gateway route entry: {line.strip()}") + return "" + + return "" + + +def get_default_ollama_base_url() -> str: + """ + 返回 Ollama 的默认 OpenAI-compatible base_url。 + + 用户显式配置 `ollama_base_url` 时不会走这里;这里只处理“未配置时的 + 最佳默认值”。容器内默认指向宿主机,普通本机运行默认指向 localhost。 + """ + if not is_running_in_container(): + return "http://localhost:11434/v1" + + if _can_resolve_hostname(_DOCKER_HOST_GATEWAY_NAME): + return f"http://{_DOCKER_HOST_GATEWAY_NAME}:11434/v1" + + gateway_ip = get_container_default_gateway_ip() + if gateway_ip: + logger.info( + "host.docker.internal is not resolvable, fallback to container " + f"default gateway for Ollama: {gateway_ip}" + ) + return f"http://{gateway_ip}:11434/v1" + + logger.warning( + "failed to resolve host.docker.internal and container default gateway; " + "fallback to host.docker.internal for Ollama" + ) + return f"http://{_DOCKER_HOST_GATEWAY_NAME}:11434/v1" + + +def load_config(): + # fix: IsADirectoryError: [Errno 21] Is a directory: '/MoneyPrinterTurbo/config.toml' + if os.path.isdir(config_file): + shutil.rmtree(config_file) + + if not os.path.isfile(config_file): + example_file = f"{root_dir}/config.example.toml" + if os.path.isfile(example_file): + shutil.copyfile(example_file, config_file) + logger.info("copy config.example.toml to config.toml") + + logger.info(f"load config from file: {config_file}") + + try: + _config_ = toml.load(config_file) + except Exception as e: + logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig") + with open(config_file, mode="r", encoding="utf-8-sig") as fp: + _cfg_content = fp.read() + _config_ = toml.loads(_cfg_content) + return _config_ + + +def save_config(): + with open(config_file, "w", encoding="utf-8") as f: + _cfg["app"] = app + _cfg["azure"] = azure + _cfg["siliconflow"] = siliconflow + _cfg["ui"] = ui + f.write(toml.dumps(_cfg)) + + +_cfg = load_config() +app = _cfg.get("app", {}) +whisper = _cfg.get("whisper", {}) +proxy = _cfg.get("proxy", {}) +azure = _cfg.get("azure", {}) +siliconflow = _cfg.get("siliconflow", {}) +ui = _cfg.get( + "ui", + { + "hide_log": False, + }, +) + +hostname = socket.gethostname() + +log_level = _cfg.get("log_level", "DEBUG") +listen_host = _cfg.get("listen_host", "0.0.0.0") +listen_port = _cfg.get("listen_port", 8099) +project_name = _cfg.get("project_name", "MoneyPrinterTurbo") +project_description = _cfg.get( + "project_description", + "https://github.com/harry0703/MoneyPrinterTurbo", +) +project_version = _cfg.get("project_version", "1.2.9") +reload_debug = False + +app["redis_host"] = os.getenv( + "MPT_APP_REDIS_HOST", + os.getenv("REDIS_HOST", app.get("redis_host", "localhost")), +) + +imagemagick_path = app.get("imagemagick_path", "") +if imagemagick_path and os.path.isfile(imagemagick_path): + os.environ["IMAGEMAGICK_BINARY"] = imagemagick_path + +ffmpeg_path = app.get("ffmpeg_path", "") +if ffmpeg_path and os.path.isfile(ffmpeg_path): + os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path + +logger.info(f"{project_name} v{project_version}") diff --git a/app/controllers/base.py b/app/controllers/base.py new file mode 100644 index 0000000..122e341 --- /dev/null +++ b/app/controllers/base.py @@ -0,0 +1,31 @@ +from uuid import uuid4 + +from fastapi import Request + +from app.config import config +from app.models.exception import HttpException + + +def get_task_id(request: Request): + task_id = request.headers.get("x-task-id") + if not task_id: + task_id = uuid4() + return str(task_id) + + +def get_api_key(request: Request): + api_key = request.headers.get("x-api-key") + return api_key + + +def verify_token(request: Request): + token = get_api_key(request) + if token != config.app.get("api_key", ""): + request_id = get_task_id(request) + request_url = request.url + user_agent = request.headers.get("user-agent") + raise HttpException( + task_id=request_id, + status_code=401, + message=f"invalid token: {request_url}, {user_agent}", + ) diff --git a/app/controllers/manager/base_manager.py b/app/controllers/manager/base_manager.py new file mode 100644 index 0000000..3480479 --- /dev/null +++ b/app/controllers/manager/base_manager.py @@ -0,0 +1,87 @@ +import threading +from typing import Any, Callable, Dict + +from loguru import logger + + +class TaskQueueFullError(ValueError): + pass + + +class TaskManager: + def __init__(self, max_concurrent_tasks: int, max_queued_tasks: int = 100): + self.max_concurrent_tasks = max_concurrent_tasks + self.max_queued_tasks = max_queued_tasks + self.current_tasks = 0 + self.lock = threading.Lock() + self.queue = self.create_queue() + + def create_queue(self): + raise NotImplementedError() + + def add_task(self, func: Callable, *args: Any, **kwargs: Any): + with self.lock: + if self.current_tasks < self.max_concurrent_tasks: + logger.info( + f"add task: {func.__name__}, current_tasks: {self.current_tasks}" + ) + self.execute_task(func, *args, **kwargs) + else: + queue_size = self.queue_size() + # 并发数已满时才进入排队。队列必须有上限,否则匿名接口可以持续 + # 堆积任务对象和请求参数,最终造成内存耗尽或第三方 API 成本失控。 + if queue_size >= self.max_queued_tasks: + logger.warning( + f"reject task: {func.__name__}, queue_size: {queue_size}, " + f"max_queued_tasks: {self.max_queued_tasks}" + ) + raise TaskQueueFullError("task queue is full, please try again later") + + logger.info( + f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}, " + f"queue_size: {queue_size}" + ) + self.enqueue({"func": func, "args": args, "kwargs": kwargs}) + + def execute_task(self, func: Callable, *args: Any, **kwargs: Any): + thread = threading.Thread( + target=self.run_task, args=(func, *args), kwargs=kwargs + ) + thread.start() + + def run_task(self, func: Callable, *args: Any, **kwargs: Any): + try: + with self.lock: + self.current_tasks += 1 + func(*args, **kwargs) # call the function here, passing *args and **kwargs. + finally: + self.task_done() + + def check_queue(self): + with self.lock: + if ( + self.current_tasks < self.max_concurrent_tasks + and not self.is_queue_empty() + ): + task_info = self.dequeue() + func = task_info["func"] + args = task_info.get("args", ()) + kwargs = task_info.get("kwargs", {}) + self.execute_task(func, *args, **kwargs) + + def task_done(self): + with self.lock: + self.current_tasks -= 1 + self.check_queue() + + def enqueue(self, task: Dict): + raise NotImplementedError() + + def dequeue(self): + raise NotImplementedError() + + def is_queue_empty(self): + raise NotImplementedError() + + def queue_size(self): + raise NotImplementedError() diff --git a/app/controllers/manager/memory_manager.py b/app/controllers/manager/memory_manager.py new file mode 100644 index 0000000..4a2612b --- /dev/null +++ b/app/controllers/manager/memory_manager.py @@ -0,0 +1,21 @@ +from queue import Queue +from typing import Dict + +from app.controllers.manager.base_manager import TaskManager + + +class InMemoryTaskManager(TaskManager): + def create_queue(self): + return Queue(maxsize=self.max_queued_tasks) + + def enqueue(self, task: Dict): + self.queue.put(task) + + def dequeue(self): + return self.queue.get() + + def is_queue_empty(self): + return self.queue.empty() + + def queue_size(self): + return self.queue.qsize() diff --git a/app/controllers/manager/redis_manager.py b/app/controllers/manager/redis_manager.py new file mode 100644 index 0000000..861af0f --- /dev/null +++ b/app/controllers/manager/redis_manager.py @@ -0,0 +1,64 @@ +import json +from typing import Dict + +import redis + +from app.controllers.manager.base_manager import TaskManager +from app.models.schema import VideoParams +from app.services import task as tm + +FUNC_MAP = { + "start": tm.start, + # 'start_test': tm.start_test +} + + +class RedisTaskManager(TaskManager): + def __init__( + self, + max_concurrent_tasks: int, + redis_url: str, + max_queued_tasks: int = 100, + ): + self.redis_client = redis.Redis.from_url(redis_url) + super().__init__(max_concurrent_tasks, max_queued_tasks=max_queued_tasks) + + def create_queue(self): + return "task_queue" + + def enqueue(self, task: Dict): + task_with_serializable_params = task.copy() + + if "params" in task["kwargs"] and isinstance( + task["kwargs"]["params"], VideoParams + ): + task_with_serializable_params["kwargs"]["params"] = task["kwargs"][ + "params" + ].dict() + + # 将函数对象转换为其名称 + task_with_serializable_params["func"] = task["func"].__name__ + self.redis_client.rpush(self.queue, json.dumps(task_with_serializable_params)) + + def dequeue(self): + task_json = self.redis_client.lpop(self.queue) + if task_json: + task_info = json.loads(task_json) + # 将函数名称转换回函数对象 + task_info["func"] = FUNC_MAP[task_info["func"]] + + if "params" in task_info["kwargs"] and isinstance( + task_info["kwargs"]["params"], dict + ): + task_info["kwargs"]["params"] = VideoParams( + **task_info["kwargs"]["params"] + ) + + return task_info + return None + + def is_queue_empty(self): + return self.redis_client.llen(self.queue) == 0 + + def queue_size(self): + return self.redis_client.llen(self.queue) diff --git a/app/controllers/ping.py b/app/controllers/ping.py new file mode 100644 index 0000000..073247b --- /dev/null +++ b/app/controllers/ping.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, Request + +router = APIRouter() + + +@router.get( + "/ping", + tags=["Health Check"], + description="检查服务可用性", + response_description="pong", +) +def ping(request: Request) -> str: + return "pong" diff --git a/app/controllers/v1/base.py b/app/controllers/v1/base.py new file mode 100644 index 0000000..1336e47 --- /dev/null +++ b/app/controllers/v1/base.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + + +def new_router(dependencies=None): + router = APIRouter() + router.tags = ["V1"] + router.prefix = "/api/v1" + # 将认证依赖项应用于所有路由 + if dependencies: + router.dependencies = dependencies + return router diff --git a/app/controllers/v1/llm.py b/app/controllers/v1/llm.py new file mode 100644 index 0000000..177dde8 --- /dev/null +++ b/app/controllers/v1/llm.py @@ -0,0 +1,47 @@ +from fastapi import Request + +from app.controllers.v1.base import new_router +from app.models.schema import ( + VideoScriptRequest, + VideoScriptResponse, + VideoTermsRequest, + VideoTermsResponse, +) +from app.services import llm +from app.utils import utils + +# authentication dependency +# router = new_router(dependencies=[Depends(base.verify_token)]) +router = new_router() + + +@router.post( + "/scripts", + response_model=VideoScriptResponse, + summary="Create a script for the video", +) +def generate_video_script(request: Request, body: VideoScriptRequest): + video_script = llm.generate_script( + video_subject=body.video_subject, + language=body.video_language, + paragraph_number=body.paragraph_number, + video_script_prompt=body.video_script_prompt, + custom_system_prompt=body.custom_system_prompt, + ) + response = {"video_script": video_script} + return utils.get_response(200, response) + + +@router.post( + "/terms", + response_model=VideoTermsResponse, + summary="Generate video terms based on the video script", +) +def generate_video_terms(request: Request, body: VideoTermsRequest): + video_terms = llm.generate_terms( + video_subject=body.video_subject, + video_script=body.video_script, + amount=body.amount, + ) + response = {"video_terms": video_terms} + return utils.get_response(200, response) diff --git a/app/controllers/v1/video.py b/app/controllers/v1/video.py new file mode 100644 index 0000000..5822fd8 --- /dev/null +++ b/app/controllers/v1/video.py @@ -0,0 +1,403 @@ +import glob +import os +import pathlib +import shutil +from typing import Union + +from fastapi import BackgroundTasks, Depends, Path, Request, UploadFile +from fastapi.params import File +from fastapi.responses import FileResponse, StreamingResponse +from loguru import logger + +from app.config import config +from app.controllers import base +from app.controllers.manager.base_manager import TaskQueueFullError +from app.controllers.manager.memory_manager import InMemoryTaskManager +from app.controllers.manager.redis_manager import RedisTaskManager +from app.controllers.v1.base import new_router +from app.models.exception import HttpException +from app.models.schema import ( + AudioRequest, + BgmRetrieveResponse, + BgmUploadResponse, + SubtitleRequest, + TaskDeletionResponse, + TaskQueryRequest, + TaskQueryResponse, + TaskResponse, + TaskVideoRequest, + VideoMaterialUploadResponse, + VideoMaterialRetrieveResponse +) +from app.services import state as sm +from app.services import task as tm +from app.utils import file_security, utils + +# 认证依赖项 +# router = new_router(dependencies=[Depends(base.verify_token)]) +router = new_router() + +_enable_redis = config.app.get("enable_redis", False) +_redis_host = config.app.get("redis_host", "localhost") +_redis_port = config.app.get("redis_port", 6379) +_redis_db = config.app.get("redis_db", 0) +_redis_password = config.app.get("redis_password", None) +_max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5) +_max_queued_tasks = config.app.get("max_queued_tasks", 100) + +redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}" +# 根据配置选择合适的任务管理器 +if _enable_redis: + task_manager = RedisTaskManager( + max_concurrent_tasks=_max_concurrent_tasks, + redis_url=redis_url, + max_queued_tasks=_max_queued_tasks, + ) +else: + task_manager = InMemoryTaskManager( + max_concurrent_tasks=_max_concurrent_tasks, + max_queued_tasks=_max_queued_tasks, + ) + + +def _sanitize_upload_filename(filename: str, request_id: str) -> str: + # 浏览器或客户端有时会附带目录信息,甚至可能夹带 ../ 这类穿越片段。 + # 这里只保留纯文件名,避免上传接口把文件写到目标目录之外。 + normalized_name = (filename or "").replace("\\", "/").split("/")[-1].strip() + if not normalized_name or normalized_name in {".", ".."}: + raise HttpException( + task_id=request_id, + status_code=400, + message=f"{request_id}: invalid filename", + ) + return normalized_name + + +def _resolve_path_within_directory(base_dir: str, unsafe_path: str, request_id: str) -> str: + try: + return file_security.resolve_path_within_directory(base_dir, unsafe_path) + except ValueError as exc: + logger.warning( + f"reject unsafe file path, request_id: {request_id}, path: {unsafe_path}, " + f"error: {str(exc)}" + ) + raise HttpException( + task_id=request_id, + status_code=404 if str(exc) == "file does not exist" else 403, + message=f"{request_id}: invalid file path", + ) + +def _task_file_to_uri(file: str, endpoint: str, task_dir: str, request_id: str) -> str: + if not isinstance(file, str): + return file + + if file.startswith(("http://", "https://")): + return file + + try: + resolved_path = file_security.resolve_path_within_directory(task_dir, file) + except ValueError as exc: + # 任务状态理论上只应保存任务目录内的产物路径。这里不再继续拼接 URL, + # 避免把异常路径包装成可访问链接;同时保留原值,便于排查历史脏数据。 + logger.warning( + f"skip unsafe task output path, request_id: {request_id}, path: {file}, " + f"error: {str(exc)}" + ) + return file + + relative_path = os.path.relpath(resolved_path, task_dir).replace("\\", "/") + uri_path = f"tasks/{relative_path}" + if endpoint: + return f"{endpoint.rstrip('/')}/{uri_path}" + return f"/{uri_path}" + + +@router.post("/videos", response_model=TaskResponse, summary="Generate a short video") +def create_video( + background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest +): + return create_task(request, body, stop_at="video") + + +@router.post("/subtitle", response_model=TaskResponse, summary="Generate subtitle only") +def create_subtitle( + background_tasks: BackgroundTasks, request: Request, body: SubtitleRequest +): + return create_task(request, body, stop_at="subtitle") + + +@router.post("/audio", response_model=TaskResponse, summary="Generate audio only") +def create_audio( + background_tasks: BackgroundTasks, request: Request, body: AudioRequest +): + return create_task(request, body, stop_at="audio") + + +def create_task( + request: Request, + body: Union[TaskVideoRequest, SubtitleRequest, AudioRequest], + stop_at: str, +): + task_id = utils.get_uuid() + request_id = base.get_task_id(request) + try: + task = { + "task_id": task_id, + "request_id": request_id, + "params": body.model_dump(), + } + sm.state.update_task(task_id) + task_manager.add_task(tm.start, task_id=task_id, params=body, stop_at=stop_at) + logger.success(f"Task created: {utils.to_json(task)}") + return utils.get_response(200, task) + except TaskQueueFullError as e: + sm.state.delete_task(task_id) + logger.warning( + f"reject task because queue is full, request_id: {request_id}, task_id: {task_id}" + ) + raise HttpException( + task_id=task_id, status_code=429, message=f"{request_id}: {str(e)}" + ) + except ValueError as e: + raise HttpException( + task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}" + ) + +from fastapi import Query + +@router.get("/tasks", response_model=TaskQueryResponse, summary="Get all tasks") +def get_all_tasks(request: Request, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1)): + request_id = base.get_task_id(request) + tasks, total = sm.state.get_all_tasks(page, page_size) + + response = { + "tasks": tasks, + "total": total, + "page": page, + "page_size": page_size, + } + return utils.get_response(200, response) + + + +@router.get( + "/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status" +) +def get_task( + request: Request, + task_id: str = Path(..., description="Task ID"), + query: TaskQueryRequest = Depends(), +): + request_id = base.get_task_id(request) + endpoint = config.app.get("endpoint", "").rstrip("/") + task = sm.state.get_task(task_id) + if task: + task_dir = utils.task_dir() + response_task = dict(task) + + if "videos" in task: + response_task["videos"] = [ + _task_file_to_uri(v, endpoint, task_dir, request_id) + for v in task["videos"] + ] + if "combined_videos" in task: + response_task["combined_videos"] = [ + _task_file_to_uri(v, endpoint, task_dir, request_id) + for v in task["combined_videos"] + ] + return utils.get_response(200, response_task) + + raise HttpException( + task_id=task_id, status_code=404, message=f"{request_id}: task not found" + ) + + +@router.delete( + "/tasks/{task_id}", + response_model=TaskDeletionResponse, + summary="Delete a generated short video task", +) +def delete_video(request: Request, task_id: str = Path(..., description="Task ID")): + request_id = base.get_task_id(request) + task = sm.state.get_task(task_id) + if task: + tasks_dir = utils.task_dir() + current_task_dir = os.path.join(tasks_dir, task_id) + if os.path.exists(current_task_dir): + shutil.rmtree(current_task_dir) + + sm.state.delete_task(task_id) + logger.success(f"video deleted: {utils.to_json(task)}") + return utils.get_response(200) + + raise HttpException( + task_id=task_id, status_code=404, message=f"{request_id}: task not found" + ) + + +@router.get( + "/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files" +) +def get_bgm_list(request: Request): + suffix = "*.mp3" + song_dir = utils.song_dir() + files = glob.glob(os.path.join(song_dir, suffix)) + bgm_list = [] + for file in files: + filename = os.path.basename(file) + bgm_list.append( + { + "name": filename, + "size": os.path.getsize(file), + # 只返回文件名,避免把服务器绝对路径暴露给调用方。 + # 服务端后续会把该文件名解析回 songs 白名单目录。 + "file": filename, + } + ) + response = {"files": bgm_list} + return utils.get_response(200, response) + + +@router.post( + "/musics", + response_model=BgmUploadResponse, + summary="Upload the BGM file to the songs directory", +) +def upload_bgm_file(request: Request, file: UploadFile = File(...)): + request_id = base.get_task_id(request) + safe_filename = _sanitize_upload_filename(file.filename, request_id) + # check file ext + if safe_filename.lower().endswith("mp3"): + song_dir = utils.song_dir() + save_path = os.path.join(song_dir, safe_filename) + # save file + with open(save_path, "wb+") as buffer: + # If the file already exists, it will be overwritten + file.file.seek(0) + buffer.write(file.file.read()) + response = {"file": safe_filename} + return utils.get_response(200, response) + + raise HttpException( + "", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded" + ) + +@router.get( + "/video_materials", response_model=VideoMaterialRetrieveResponse, summary="Retrieve local video materials" +) +def get_video_materials_list(request: Request): + allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png") + local_videos_dir = utils.storage_dir("local_videos", create=True) + files = [] + for suffix in allowed_suffixes: + files.extend(glob.glob(os.path.join(local_videos_dir, f"*.{suffix}"))) + # 文件系统枚举顺序不稳定,直接返回会导致“顺序拼接”在不同机器或不同 + # 时刻表现不一致。这里统一按文件名排序,至少保证服务端返回顺序可预测。 + files.sort(key=lambda file_path: os.path.basename(file_path).lower()) + video_materials_list = [] + for file in files: + filename = os.path.basename(file) + video_materials_list.append( + { + "name": filename, + "size": os.path.getsize(file), + # 与 BGM 一样,只返回文件名;创建任务时再在 local_videos + # 白名单目录内解析,避免 API 泄露宿主机绝对路径。 + "file": filename, + } + ) + response = {"files": video_materials_list} + return utils.get_response(200, response) + + +@router.post( + "/video_materials", + response_model=VideoMaterialUploadResponse, + summary="Upload the video material file to the local videos directory", +) +def upload_video_material_file(request: Request, file: UploadFile = File(...)): + request_id = base.get_task_id(request) + safe_filename = _sanitize_upload_filename(file.filename, request_id) + # check file ext + allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png") + normalized_filename = safe_filename.lower() + # 统一按小写扩展名校验,兼容 .MOV 这类大写后缀文件。 + if normalized_filename.endswith(allowed_suffixes): + local_videos_dir = utils.storage_dir("local_videos", create=True) + save_path = os.path.join(local_videos_dir, safe_filename) + # save file + with open(save_path, "wb+") as buffer: + # If the file already exists, it will be overwritten + file.file.seek(0) + buffer.write(file.file.read()) + response = {"file": safe_filename} + return utils.get_response(200, response) + + raise HttpException( + "", status_code=400, message=f"{request_id}: Only files with extensions {', '.join(allowed_suffixes)} can be uploaded" + ) + +@router.get("/stream/{file_path:path}") +async def stream_video(request: Request, file_path: str): + request_id = base.get_task_id(request) + tasks_dir = utils.task_dir() + video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id) + range_header = request.headers.get("Range") + video_size = os.path.getsize(video_path) + start, end = 0, video_size - 1 + + length = video_size + if range_header: + range_ = range_header.split("bytes=")[1] + start, end = [int(part) if part else None for part in range_.split("-")] + if start is None: + start = video_size - end + end = video_size - 1 + if end is None: + end = video_size - 1 + length = end - start + 1 + + def file_iterator(file_path, offset=0, bytes_to_read=None): + with open(file_path, "rb") as f: + f.seek(offset, os.SEEK_SET) + remaining = bytes_to_read or video_size + while remaining > 0: + bytes_to_read = min(4096, remaining) + data = f.read(bytes_to_read) + if not data: + break + remaining -= len(data) + yield data + + response = StreamingResponse( + file_iterator(video_path, start, length), media_type="video/mp4" + ) + response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}" + response.headers["Accept-Ranges"] = "bytes" + response.headers["Content-Length"] = str(length) + response.status_code = 206 # Partial Content + + return response + + +@router.get("/download/{file_path:path}") +async def download_video(request: Request, file_path: str): + """ + download video + :param request: Request request + :param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4 + :return: video file + """ + request_id = base.get_task_id(request) + tasks_dir = utils.task_dir() + video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id) + file_path = pathlib.Path(video_path) + filename = file_path.stem + extension = file_path.suffix + headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"} + return FileResponse( + path=video_path, + headers=headers, + filename=f"{filename}{extension}", + media_type=f"video/{extension[1:]}", + ) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/const.py b/app/models/const.py new file mode 100644 index 0000000..e7540ef --- /dev/null +++ b/app/models/const.py @@ -0,0 +1,25 @@ +PUNCTUATIONS = [ + "?", + ",", + ".", + "、", + ";", + ":", + "!", + "…", + "?", + ",", + "。", + "、", + ";", + ":", + "!", + "...", +] + +TASK_STATE_FAILED = -1 +TASK_STATE_COMPLETE = 1 +TASK_STATE_PROCESSING = 4 + +FILE_TYPE_VIDEOS = ["mp4", "mov", "mkv", "webm"] +FILE_TYPE_IMAGES = ["jpg", "jpeg", "png", "bmp"] diff --git a/app/models/exception.py b/app/models/exception.py new file mode 100644 index 0000000..d938cb5 --- /dev/null +++ b/app/models/exception.py @@ -0,0 +1,28 @@ +import traceback +from typing import Any + +from loguru import logger + + +class HttpException(Exception): + def __init__( + self, task_id: str, status_code: int, message: str = "", data: Any = None + ): + self.message = message + self.status_code = status_code + self.data = data + # Retrieve the exception stack trace information. + tb_str = traceback.format_exc().strip() + if not tb_str or tb_str == "NoneType: None": + msg = f"HttpException: {status_code}, {task_id}, {message}" + else: + msg = f"HttpException: {status_code}, {task_id}, {message}\n{tb_str}" + + if status_code == 400: + logger.warning(msg) + else: + logger.error(msg) + + +class FileNotFoundException(Exception): + pass diff --git a/app/models/schema.py b/app/models/schema.py new file mode 100644 index 0000000..ef7958f --- /dev/null +++ b/app/models/schema.py @@ -0,0 +1,342 @@ +import warnings +from enum import Enum +from typing import Any, List, Optional, Union + +import pydantic +from pydantic import BaseModel, Field + +from app.config import config + +# 忽略 Pydantic 的特定警告 +warnings.filterwarnings( + "ignore", + category=UserWarning, + message="Field name.*shadows an attribute in parent.*", +) + + +class VideoConcatMode(str, Enum): + random = "random" + sequential = "sequential" + + +class VideoTransitionMode(str, Enum): + none = None + shuffle = "Shuffle" + fade_in = "FadeIn" + fade_out = "FadeOut" + slide_in = "SlideIn" + slide_out = "SlideOut" + + +class VideoAspect(str, Enum): + landscape = "16:9" + portrait = "9:16" + square = "1:1" + + def to_resolution(self): + if self == VideoAspect.landscape.value: + return 1920, 1080 + elif self == VideoAspect.portrait.value: + return 1080, 1920 + elif self == VideoAspect.square.value: + return 1080, 1080 + return 1080, 1920 + + +class _Config: + arbitrary_types_allowed = True + + +@pydantic.dataclasses.dataclass(config=_Config) +class MaterialInfo: + provider: str = "pexels" + url: str = "" + duration: int = 0 + + +class VideoParams(BaseModel): + """ + { + "video_subject": "", + "video_aspect": "横屏 16:9(西瓜视频)", + "voice_name": "女生-晓晓", + "bgm_name": "random", + "font_name": "STHeitiMedium 黑体-中", + "text_color": "#FFFFFF", + "font_size": 60, + "stroke_color": "#000000", + "stroke_width": 1.5 + } + """ + + video_subject: str + video_script: str = "" # Script used to generate the video + video_terms: Optional[str | list] = None # Keywords used to generate the video + video_aspect: Optional[VideoAspect] = VideoAspect.portrait.value + video_concat_mode: Optional[VideoConcatMode] = VideoConcatMode.random.value + video_transition_mode: Optional[VideoTransitionMode] = None + video_clip_duration: Optional[int] = 5 + video_count: Optional[int] = 1 + + video_source: Optional[str] = "pexels" + video_materials: Optional[List[MaterialInfo]] = ( + None # Materials used to generate the video + ) + + custom_audio_file: Optional[str] = None # Custom audio file path, will ignore video_script and disable subtitle + video_language: Optional[str] = "" # auto detect + + voice_name: Optional[str] = "" + voice_volume: Optional[float] = 1.0 + voice_rate: Optional[float] = 1.0 + bgm_type: Optional[str] = "random" + bgm_file: Optional[str] = "" + bgm_volume: Optional[float] = 0.2 + + subtitle_enabled: Optional[bool] = True + subtitle_position: Optional[str] = config.ui.get("subtitle_position", "bottom") # top, bottom, center, custom + custom_position: float = config.ui.get("custom_position", 70.0) + font_name: Optional[str] = "STHeitiMedium.ttc" + text_fore_color: Optional[str] = "#FFFFFF" + text_background_color: Union[bool, str] = True + + font_size: int = 60 + stroke_color: Optional[str] = "#000000" + stroke_width: float = 1.5 + n_threads: Optional[int] = 2 + paragraph_number: int = Field(default=1, ge=1, le=10) + video_script_prompt: str = Field(default="", max_length=2000) + custom_system_prompt: str = Field(default="", max_length=8000) + + +class SubtitleRequest(BaseModel): + video_script: str + video_language: Optional[str] = "" + voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female" + voice_volume: Optional[float] = 1.0 + voice_rate: Optional[float] = 1.2 + bgm_type: Optional[str] = "random" + bgm_file: Optional[str] = "" + bgm_volume: Optional[float] = 0.2 + subtitle_position: Optional[str] = config.ui.get("subtitle_position", "bottom") + font_name: Optional[str] = "STHeitiMedium.ttc" + text_fore_color: Optional[str] = "#FFFFFF" + text_background_color: Union[bool, str] = True + font_size: int = 60 + stroke_color: Optional[str] = "#000000" + stroke_width: float = 1.5 + video_source: Optional[str] = "local" + subtitle_enabled: Optional[str] = "true" + + +class AudioRequest(BaseModel): + video_script: str + video_language: Optional[str] = "" + voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female" + voice_volume: Optional[float] = 1.0 + voice_rate: Optional[float] = 1.2 + bgm_type: Optional[str] = "random" + bgm_file: Optional[str] = "" + bgm_volume: Optional[float] = 0.2 + video_source: Optional[str] = "local" + + +class VideoScriptParams: + """ + { + "video_subject": "春天的花海", + "video_language": "", + "paragraph_number": 1, + "video_script_prompt": "", + "custom_system_prompt": "" + } + """ + + video_subject: Optional[str] = "春天的花海" + video_language: Optional[str] = "" + paragraph_number: int = Field(default=1, ge=1, le=10) + video_script_prompt: str = Field(default="", max_length=2000) + custom_system_prompt: str = Field(default="", max_length=8000) + + +class VideoTermsParams: + """ + { + "video_subject": "", + "video_script": "", + "amount": 5 + } + """ + + video_subject: Optional[str] = "春天的花海" + video_script: Optional[str] = ( + "春天的花海,如诗如画般展现在眼前。万物复苏的季节里,大地披上了一袭绚丽多彩的盛装。金黄的迎春、粉嫩的樱花、洁白的梨花、艳丽的郁金香……" + ) + amount: Optional[int] = 5 + + +class BaseResponse(BaseModel): + status: int = 200 + message: Optional[str] = "success" + data: Any = None + + +class TaskVideoRequest(VideoParams, BaseModel): + pass + + +class TaskQueryRequest(BaseModel): + pass + + +class VideoScriptRequest(VideoScriptParams, BaseModel): + pass + + +class VideoTermsRequest(VideoTermsParams, BaseModel): + pass + + +###################################################################################################### +###################################################################################################### +###################################################################################################### +###################################################################################################### +class TaskResponse(BaseResponse): + class TaskResponseData(BaseModel): + task_id: str + + data: TaskResponseData + + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": {"task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558"}, + }, + } + + +class TaskQueryResponse(BaseResponse): + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": { + "state": 1, + "progress": 100, + "videos": [ + "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4" + ], + "combined_videos": [ + "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4" + ], + }, + }, + } + + +class TaskDeletionResponse(BaseResponse): + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": { + "state": 1, + "progress": 100, + "videos": [ + "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4" + ], + "combined_videos": [ + "http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4" + ], + }, + }, + } + + +class VideoScriptResponse(BaseResponse): + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": { + "video_script": "春天的花海,是大自然的一幅美丽画卷。在这个季节里,大地复苏,万物生长,花朵争相绽放,形成了一片五彩斑斓的花海..." + }, + }, + } + + +class VideoTermsResponse(BaseResponse): + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": {"video_terms": ["sky", "tree"]}, + }, + } + + +class BgmRetrieveResponse(BaseResponse): + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": { + "files": [ + { + "name": "output013.mp3", + "size": 1891269, + "file": "/MoneyPrinterTurbo/resource/songs/output013.mp3", + } + ] + }, + }, + } + + +class BgmUploadResponse(BaseResponse): + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": {"file": "/MoneyPrinterTurbo/resource/songs/example.mp3"}, + }, + } + +class VideoMaterialRetrieveResponse(BaseResponse): + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": { + "files": [ + { + "name": "example.mp4", + "size": 12345678, + "file": "/MoneyPrinterTurbo/resource/videos/example.mp4", + } + ] + }, + }, + } + +class VideoMaterialUploadResponse(BaseResponse): + class Config: + json_schema_extra = { + "example": { + "status": 200, + "message": "success", + "data": { + "file": "/MoneyPrinterTurbo/resource/videos/example.mp4", + }, + }, + } diff --git a/app/router.py b/app/router.py new file mode 100644 index 0000000..cf84037 --- /dev/null +++ b/app/router.py @@ -0,0 +1,17 @@ +"""Application configuration - root APIRouter. + +Defines all FastAPI application endpoints. + +Resources: + 1. https://fastapi.tiangolo.com/tutorial/bigger-applications + +""" + +from fastapi import APIRouter + +from app.controllers.v1 import llm, video + +root_api_router = APIRouter() +# v1 +root_api_router.include_router(video.router) +root_api_router.include_router(llm.router) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/llm.py b/app/services/llm.py new file mode 100644 index 0000000..9630eaa --- /dev/null +++ b/app/services/llm.py @@ -0,0 +1,714 @@ +import json +import logging +import re +import requests +from typing import List + +from loguru import logger +from openai import AzureOpenAI, OpenAI +from openai.types.chat import ChatCompletion + +from app.config import config + +_max_retries = 5 +_DEFAULT_GEMINI_MODEL = "gemini-2.5-flash" +_DEPRECATED_GEMINI_MODELS = {"gemini-pro", "gemini-1.0-pro"} +MIN_SCRIPT_PARAGRAPH_NUMBER = 1 +MAX_SCRIPT_PARAGRAPH_NUMBER = 10 +MAX_SCRIPT_PROMPT_LENGTH = 2000 +MAX_SCRIPT_SYSTEM_PROMPT_LENGTH = 8000 + +DEFAULT_SCRIPT_SYSTEM_PROMPT = """ +# Role: Video Script Generator + +## Goals: +Generate a script for a video, depending on the subject of the video. + +## Constrains: +1. the script is to be returned as a string with the specified number of paragraphs. +2. do not under any circumstance reference this prompt in your response. +3. get straight to the point, don't start with unnecessary things like, "welcome to this video". +4. you must not include any type of markdown or formatting in the script, never use a title. +5. only return the raw content of the script. +6. do not include "voiceover", "narrator" or similar indicators of what should be spoken at the beginning of each paragraph or line. +7. you must not mention the prompt, or anything about the script itself. also, never talk about the amount of paragraphs or lines. just write the script. +8. respond in the same language as the video subject. +""".strip() + + +def _normalize_text_response(content, llm_provider: str) -> str: + # 不同 LLM SDK 在异常或被拦截场景下,可能返回 None、空字符串, + # 甚至返回非字符串对象。这里统一做兜底校验,避免后续直接调用 + # `.replace()` 时抛出 `NoneType` 之类的属性错误。 + if content is None: + raise ValueError(f"[{llm_provider}] returned empty text content") + + if not isinstance(content, str): + raise TypeError( + f"[{llm_provider}] returned non-text content: {type(content).__name__}" + ) + + content = content.strip() + if not content: + raise ValueError(f"[{llm_provider}] returned empty text content") + + return content.replace("\n", "") + + +def _extract_chat_completion_text(response, llm_provider: str) -> str: + # OpenAI 兼容接口在异常场景下,可能返回没有 choices、 + # 或者 choices/message/content 为空的响应对象。 + # 这里统一做结构校验,避免出现 `NoneType is not subscriptable` + # 这类底层属性访问错误。 + choices = getattr(response, "choices", None) + if not choices: + raise ValueError(f"[{llm_provider}] returned empty choices") + + first_choice = choices[0] + message = getattr(first_choice, "message", None) + if message is None: + raise ValueError(f"[{llm_provider}] returned empty message") + + content = getattr(message, "content", None) + return _normalize_text_response(content, llm_provider) + + +def _generate_response(prompt: str) -> str: + try: + content = "" + llm_provider = config.app.get("llm_provider", "openai") + logger.info(f"llm provider: {llm_provider}") + if llm_provider == "g4f": + if not config.app.get("enable_g4f", False): + raise ValueError( + "g4f provider is disabled by default because it relies on " + "reverse-engineered third-party endpoints. Set enable_g4f=true " + "in config.toml only if you understand and accept the security, " + "reliability, and legal risks." + ) + + logger.warning( + "g4f provider is enabled. This provider may be unstable and carries " + "supply-chain and terms-of-service risks. Prefer official providers, " + "OpenAI-compatible APIs, LiteLLM, Ollama, or local inference for production." + ) + try: + import g4f + except ImportError as e: + raise ValueError( + "g4f package is not installed by default. Install the optional " + "dependency with `uv sync --extra g4f` only if you understand " + "and accept the provider risks." + ) from e + + model_name = config.app.get("g4f_model_name", "") + if not model_name: + model_name = "gpt-3.5-turbo-16k-0613" + content = g4f.ChatCompletion.create( + model=model_name, + messages=[{"role": "user", "content": prompt}], + ) + else: + api_version = "" # for azure + if llm_provider == "moonshot": + api_key = config.app.get("moonshot_api_key") + model_name = config.app.get("moonshot_model_name") + base_url = "https://api.moonshot.cn/v1" + elif llm_provider == "ollama": + # api_key = config.app.get("openai_api_key") + api_key = "ollama" # any string works but you are required to have one + model_name = config.app.get("ollama_model_name") + base_url = config.app.get("ollama_base_url", "") + if not base_url: + base_url = config.get_default_ollama_base_url() + elif llm_provider == "openai": + api_key = config.app.get("openai_api_key") + model_name = config.app.get("openai_model_name") + base_url = config.app.get("openai_base_url", "") + if not base_url: + base_url = "https://api.openai.com/v1" + elif llm_provider == "oneapi": + api_key = config.app.get("oneapi_api_key") + model_name = config.app.get("oneapi_model_name") + base_url = config.app.get("oneapi_base_url", "") + elif llm_provider == "azure": + api_key = config.app.get("azure_api_key") + model_name = config.app.get("azure_model_name") + base_url = config.app.get("azure_base_url", "") + api_version = config.app.get("azure_api_version", "2024-02-15-preview") + elif llm_provider == "gemini": + api_key = config.app.get("gemini_api_key") + model_name = config.app.get("gemini_model_name") + base_url = config.app.get("gemini_base_url", "") + # Gemini 旧模型名已经陆续下线,这里自动兼容历史配置, + # 避免用户沿用旧值时直接收到 404。 + if not model_name: + model_name = _DEFAULT_GEMINI_MODEL + elif model_name in _DEPRECATED_GEMINI_MODELS: + logger.warning( + f"gemini model '{model_name}' is deprecated, fallback to '{_DEFAULT_GEMINI_MODEL}'" + ) + model_name = _DEFAULT_GEMINI_MODEL + elif llm_provider == "grok": + api_key = config.app.get("grok_api_key") + model_name = config.app.get("grok_model_name") + base_url = config.app.get("grok_base_url", "") + if not base_url: + base_url = "https://api.x.ai/v1" + elif llm_provider == "qwen": + api_key = config.app.get("qwen_api_key") + model_name = config.app.get("qwen_model_name") + base_url = "***" + elif llm_provider == "cloudflare": + api_key = config.app.get("cloudflare_api_key") + model_name = config.app.get("cloudflare_model_name") + account_id = config.app.get("cloudflare_account_id") + base_url = "***" + elif llm_provider == "minimax": + api_key = config.app.get("minimax_api_key") + model_name = config.app.get("minimax_model_name") + base_url = config.app.get("minimax_base_url", "") + if not base_url: + base_url = "https://api.minimax.io/v1" + elif llm_provider == "mimo": + api_key = config.app.get("mimo_api_key") + model_name = config.app.get("mimo_model_name") + base_url = config.app.get("mimo_base_url", "") + # Xiaomi MiMo 官方文档说明其兼容 OpenAI Chat Completions 协议。 + # 这里使用独立 provider 保存默认地址和模型名,用户不用把 MiMo + # 当作 OpenAI 自定义 base_url 配置,也便于后续继续接入 MiMo + # 多模态或 TTS 能力时保持边界清晰。 + if not base_url: + base_url = "https://api.xiaomimimo.com/v1" + if not model_name: + model_name = "mimo-v2.5-pro" + elif llm_provider == "deepseek": + api_key = config.app.get("deepseek_api_key") + model_name = config.app.get("deepseek_model_name") + base_url = config.app.get("deepseek_base_url") + if not base_url: + base_url = "https://api.deepseek.com" + elif llm_provider == "modelscope": + api_key = config.app.get("modelscope_api_key") + model_name = config.app.get("modelscope_model_name") + base_url = config.app.get("modelscope_base_url") + if not base_url: + base_url = "https://api-inference.modelscope.cn/v1/" + elif llm_provider == "ernie": + api_key = config.app.get("ernie_api_key") + secret_key = config.app.get("ernie_secret_key") + base_url = config.app.get("ernie_base_url") + model_name = "***" + if not secret_key: + raise ValueError( + f"{llm_provider}: secret_key is not set, please set it in the config.toml file." + ) + elif llm_provider == "pollinations": + try: + base_url = config.app.get("pollinations_base_url", "") + if not base_url: + base_url = "https://text.pollinations.ai/openai" + model_name = config.app.get("pollinations_model_name", "openai-fast") + + # Prepare the payload + payload = { + "model": model_name, + "messages": [ + {"role": "user", "content": prompt} + ], + "seed": 101 # Optional but helps with reproducibility + } + + # Optional parameters if configured + if config.app.get("pollinations_private"): + payload["private"] = True + if config.app.get("pollinations_referrer"): + payload["referrer"] = config.app.get("pollinations_referrer") + + headers = { + "Content-Type": "application/json" + } + + # Make the API request + response = requests.post(base_url, headers=headers, json=payload) + response.raise_for_status() + result = response.json() + + if result and "choices" in result and len(result["choices"]) > 0: + content = result["choices"][0]["message"]["content"] + return _normalize_text_response(content, llm_provider) + else: + raise Exception(f"[{llm_provider}] returned an invalid response format") + + except requests.exceptions.RequestException as e: + raise Exception(f"[{llm_provider}] request failed: {str(e)}") + except Exception as e: + raise Exception(f"[{llm_provider}] error: {str(e)}") + + elif llm_provider == "litellm": + model_name = config.app.get("litellm_model_name") + + if llm_provider not in ["pollinations", "ollama", "litellm"]: # Skip validation for providers that don't require API key + if not api_key: + raise ValueError( + f"{llm_provider}: api_key is not set, please set it in the config.toml file." + ) + if not model_name: + raise ValueError( + f"{llm_provider}: model_name is not set, please set it in the config.toml file." + ) + if not base_url and llm_provider not in ["gemini"]: + raise ValueError( + f"{llm_provider}: base_url is not set, please set it in the config.toml file." + ) + + if llm_provider == "qwen": + import dashscope + from dashscope.api_entities.dashscope_response import GenerationResponse + + dashscope.api_key = api_key + response = dashscope.Generation.call( + model=model_name, messages=[{"role": "user", "content": prompt}] + ) + if response: + if isinstance(response, GenerationResponse): + status_code = response.status_code + if status_code != 200: + raise Exception( + f'[{llm_provider}] returned an error response: "{response}"' + ) + + content = response["output"]["text"] + return content.replace("\n", "") + else: + raise Exception( + f'[{llm_provider}] returned an invalid response: "{response}"' + ) + else: + raise Exception(f"[{llm_provider}] returned an empty response") + + if llm_provider == "gemini": + import google.generativeai as genai + + if not base_url: + genai.configure(api_key=api_key, transport="rest") + else: + genai.configure(api_key=api_key, transport="rest", client_options={'api_endpoint': base_url}) + + generation_config = { + "temperature": 0.5, + "top_p": 1, + "top_k": 1, + "max_output_tokens": 2048, + } + + safety_settings = [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH", + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH", + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH", + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH", + }, + ] + + model = genai.GenerativeModel( + model_name=model_name, + generation_config=generation_config, + safety_settings=safety_settings, + ) + + try: + response = model.generate_content(prompt) + candidates = response.candidates + generated_text = candidates[0].content.parts[0].text + except (AttributeError, IndexError) as e: + logger.warning( + f"gemini returned invalid response content: {str(e)}" + ) + raise ValueError( + f"[{llm_provider}] returned invalid response content" + ) + + return _normalize_text_response(generated_text, llm_provider) + + if llm_provider == "cloudflare": + response = requests.post( + f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}", + headers={"Authorization": f"Bearer {api_key}"}, + json={ + "messages": [ + { + "role": "system", + "content": "You are a friendly assistant", + }, + {"role": "user", "content": prompt}, + ] + }, + ) + result = response.json() + logger.info(result) + return _normalize_text_response(result["result"]["response"], llm_provider) + + if llm_provider == "ernie": + response = requests.post( + "https://aip.baidubce.com/oauth/2.0/token", + params={ + "grant_type": "client_credentials", + "client_id": api_key, + "client_secret": secret_key, + } + ) + access_token = response.json().get("access_token") + url = f"{base_url}?access_token={access_token}" + + payload = json.dumps( + { + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.5, + "top_p": 0.8, + "penalty_score": 1, + "disable_search": False, + "enable_citation": False, + "response_format": "text", + } + ) + headers = {"Content-Type": "application/json"} + + response = requests.request( + "POST", url, headers=headers, data=payload + ).json() + return _normalize_text_response(response.get("result"), llm_provider) + + if llm_provider == "litellm": + import litellm + + if not model_name: + raise ValueError( + f"{llm_provider}: model_name is not set, please set it in the config.toml file." + ) + + response = litellm.completion( + model=model_name, + messages=[{"role": "user", "content": prompt}], + drop_params=True, + ) + + if not response: + raise ValueError(f"[{llm_provider}] returned empty response") + if not getattr(response, "choices", None): + raise ValueError(f"[{llm_provider}] returned empty response") + + return _extract_chat_completion_text(response, llm_provider) + + if llm_provider == "azure": + # Azure OpenAI SDK 使用 `azure_endpoint` 和 `api_version` 生成专用请求地址, + # 不能继续复用下面普通 OpenAI-compatible 的 `base_url` 初始化逻辑。 + # 这里在 Azure 分支内完成请求并立即返回,避免客户端被后续 fallback + # 覆盖,导致用户配置的 Azure 凭证通过校验但实际请求没有被使用。 + logger.info(f"requesting azure chat completion, model: {model_name}") + client = AzureOpenAI( + api_key=api_key, + api_version=api_version, + azure_endpoint=base_url, + ) + response = client.chat.completions.create( + model=model_name, messages=[{"role": "user", "content": prompt}] + ) + if response: + if isinstance(response, ChatCompletion): + return _extract_chat_completion_text(response, llm_provider) + else: + raise Exception( + f'[{llm_provider}] returned an invalid response: "{response}", please check your network ' + f"connection and try again." + ) + else: + raise Exception( + f"[{llm_provider}] returned an empty response, please check your network connection and try again." + ) + + if llm_provider == "modelscope": + content = '' + client = OpenAI( + api_key=api_key, + base_url=base_url, + ) + response = client.chat.completions.create( + model=model_name, + messages=[{"role": "user", "content": prompt}], + extra_body={"enable_thinking": False}, + stream=True + ) + if response: + for chunk in response: + if not chunk.choices: + continue + delta = chunk.choices[0].delta + if delta and delta.content: + content += delta.content + + if not content.strip(): + raise ValueError("Empty content in stream response") + + return _normalize_text_response(content, llm_provider) + else: + raise Exception(f"[{llm_provider}] returned an empty response") + + else: + client = OpenAI( + api_key=api_key, + base_url=base_url, + ) + + response = client.chat.completions.create( + model=model_name, messages=[{"role": "user", "content": prompt}] + ) + if response: + if isinstance(response, ChatCompletion): + return _extract_chat_completion_text(response, llm_provider) + else: + raise Exception( + f'[{llm_provider}] returned an invalid response: "{response}", please check your network ' + f"connection and try again." + ) + else: + raise Exception( + f"[{llm_provider}] returned an empty response, please check your network connection and try again." + ) + + return _normalize_text_response(content, llm_provider) + except Exception as e: + return f"Error: {str(e)}" + + +def _limit_script_text(text: str | None, max_length: int, field_name: str) -> str: + value = (text or "").strip() + if len(value) <= max_length: + return value + + # API 层已经用 Pydantic 做长度校验;这里继续兜底,是为了保护 + # WebUI 或内部服务直接调用 generate_script 时不会把超长提示词发送给模型, + # 避免 token 成本异常和请求失败。 + logger.warning( + f"{field_name} is too long and will be truncated to {max_length} characters." + ) + return value[:max_length] + + +def _normalize_script_paragraph_number(paragraph_number: int | None) -> int: + try: + value = int(paragraph_number or MIN_SCRIPT_PARAGRAPH_NUMBER) + except (TypeError, ValueError): + value = MIN_SCRIPT_PARAGRAPH_NUMBER + + if value < MIN_SCRIPT_PARAGRAPH_NUMBER or value > MAX_SCRIPT_PARAGRAPH_NUMBER: + # WebUI 和 API 都会限制范围;这里兜底处理内部调用,避免异常参数直接扩大 + # LLM 生成成本或生成空结果。 + logger.warning( + "script paragraph_number is out of range and will be clamped: " + f"{value}" + ) + return max(MIN_SCRIPT_PARAGRAPH_NUMBER, min(value, MAX_SCRIPT_PARAGRAPH_NUMBER)) + + return value + + +def build_script_prompt( + video_subject: str, + language: str = "", + paragraph_number: int = 1, + video_script_prompt: str = "", + custom_system_prompt: str = "", +) -> str: + paragraph_number = _normalize_script_paragraph_number(paragraph_number) + video_script_prompt = _limit_script_text( + video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt" + ) + custom_system_prompt = _limit_script_text( + custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt" + ) + + # 将“脚本生成规则”和“运行时上下文”分开拼接。这样高级用户即使覆盖默认 + # system prompt,也不会漏掉视频主题、语言、段落数这些每次生成都必须带上的参数。 + prompt = custom_system_prompt or DEFAULT_SCRIPT_SYSTEM_PROMPT + prompt += f""" + +# Initialization: +- video subject: {video_subject} +- number of paragraphs: {paragraph_number} +""".rstrip() + if language: + prompt += f"\n- language: {language}" + if video_script_prompt: + prompt += f""" + +# Additional User Requirements: +{video_script_prompt} +""".rstrip() + + return prompt + + +def generate_script( + video_subject: str, + language: str = "", + paragraph_number: int = 1, + video_script_prompt: str = "", + custom_system_prompt: str = "", +) -> str: + paragraph_number = _normalize_script_paragraph_number(paragraph_number) + video_script_prompt = _limit_script_text( + video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt" + ) + custom_system_prompt = _limit_script_text( + custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt" + ) + prompt = build_script_prompt( + video_subject=video_subject, + language=language, + paragraph_number=paragraph_number, + video_script_prompt=video_script_prompt, + custom_system_prompt=custom_system_prompt, + ) + final_script = "" + logger.info( + "generating video script: " + f"subject={video_subject}, paragraph_number={paragraph_number}, " + f"has_custom_prompt={bool(video_script_prompt.strip())}, " + f"has_custom_system_prompt={bool(custom_system_prompt.strip())}" + ) + + def format_response(response): + # Clean the script + # Remove asterisks, hashes + response = response.replace("*", "") + response = response.replace("#", "") + + # Remove markdown syntax + response = re.sub(r"\[.*\]", "", response) + response = re.sub(r"\(.*\)", "", response) + + # Split the script into paragraphs + paragraphs = response.split("\n\n") + + # Select the specified number of paragraphs + # selected_paragraphs = paragraphs[:paragraph_number] + + # Join the selected paragraphs into a single string + return "\n\n".join(paragraphs) + + for i in range(_max_retries): + try: + response = _generate_response(prompt=prompt) + if response: + final_script = format_response(response) + else: + logging.error("gpt returned an empty response") + + # g4f may return an error message + if final_script and "当日额度已消耗完" in final_script: + raise ValueError(final_script) + + if final_script: + break + except Exception as e: + logger.error(f"failed to generate script: {e}") + + if i < _max_retries: + logger.warning(f"failed to generate video script, trying again... {i + 1}") + if "Error: " in final_script: + logger.error(f"failed to generate video script: {final_script}") + else: + logger.success(f"completed: \n{final_script}") + return final_script.strip() + + +def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]: + prompt = f""" +# Role: Video Search Terms Generator + +## Goals: +Generate {amount} search terms for stock videos, depending on the subject of a video. + +## Constrains: +1. the search terms are to be returned as a json-array of strings. +2. each search term should consist of 1-3 words, always add the main subject of the video. +3. you must only return the json-array of strings. you must not return anything else. you must not return the script. +4. the search terms must be related to the subject of the video. +5. reply with english search terms only. + +## Output Example: +["search term 1", "search term 2", "search term 3","search term 4","search term 5"] + +## Context: +### Video Subject +{video_subject} + +### Video Script +{video_script} + +Please note that you must use English for generating video search terms; Chinese is not accepted. +""".strip() + + logger.info(f"subject: {video_subject}") + + search_terms = [] + response = "" + for i in range(_max_retries): + try: + response = _generate_response(prompt) + if "Error: " in response: + logger.error(f"failed to generate video script: {response}") + return response + search_terms = json.loads(response) + if not isinstance(search_terms, list) or not all( + isinstance(term, str) for term in search_terms + ): + logger.error("response is not a list of strings.") + continue + + except Exception as e: + logger.warning(f"failed to generate video terms: {str(e)}") + if response: + match = re.search(r"\[.*]", response) + if match: + try: + search_terms = json.loads(match.group()) + except Exception as e: + # 这里保留重试流程,但必须记录 LLM 返回的非标准 JSON, + # 否则后续排查搜索词为空时无法定位 + # 是模型格式问题还是解析逻辑问题。 + logger.warning(f"failed to generate video terms: {str(e)}") + + if search_terms and len(search_terms) > 0: + break + if i < _max_retries: + logger.warning(f"failed to generate video terms, trying again... {i + 1}") + + logger.success(f"completed: \n{search_terms}") + return search_terms + + +if __name__ == "__main__": + video_subject = "生命的意义是什么" + script = generate_script( + video_subject=video_subject, language="zh-CN", paragraph_number=1 + ) + print("######################") + print(script) + search_terms = generate_terms( + video_subject=video_subject, video_script=script, amount=5 + ) + print("######################") + print(search_terms) + diff --git a/app/services/material.py b/app/services/material.py new file mode 100644 index 0000000..754f68b --- /dev/null +++ b/app/services/material.py @@ -0,0 +1,299 @@ +import os +import random +import threading +from typing import List +from urllib.parse import urlencode + +import requests +from loguru import logger +from moviepy.video.io.VideoFileClip import VideoFileClip + +from app.config import config +from app.models.schema import MaterialInfo, VideoAspect, VideoConcatMode +from app.utils import utils + +# Thread-safe counter for API key rotation +_api_key_counter = 0 +_api_key_lock = threading.Lock() + + +def _get_tls_verify() -> bool: + # 默认开启 TLS 证书校验,防止素材搜索和下载过程被中间人篡改。 + # 仅在企业代理、自签证书等明确需要的场景下,允许用户通过 + # `config.toml` 显式设置 `tls_verify = false` 临时关闭。 + tls_verify = config.app.get("tls_verify", True) + if isinstance(tls_verify, str): + tls_verify = tls_verify.strip().lower() not in ("0", "false", "no", "off") + + if not tls_verify: + logger.warning( + "TLS certificate verification is disabled by config.app.tls_verify=false. " + "Only use this in trusted proxy environments." + ) + + return bool(tls_verify) + + +def get_api_key(cfg_key: str): + api_keys = config.app.get(cfg_key) + if not api_keys: + raise ValueError( + f"\n\n##### {cfg_key} is not set #####\n\nPlease set it in the config.toml file: {config.config_file}\n\n" + f"{utils.to_json(config.app)}" + ) + + # if only one key is provided, return it + if isinstance(api_keys, str): + return api_keys + + global _api_key_counter + with _api_key_lock: + _api_key_counter += 1 + return api_keys[_api_key_counter % len(api_keys)] + + +def search_videos_pexels( + search_term: str, + minimum_duration: int, + video_aspect: VideoAspect = VideoAspect.portrait, +) -> List[MaterialInfo]: + aspect = VideoAspect(video_aspect) + video_orientation = aspect.name + video_width, video_height = aspect.to_resolution() + api_key = get_api_key("pexels_api_keys") + headers = { + "Authorization": api_key, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36", + } + # Build URL + params = {"query": search_term, "per_page": 20, "orientation": video_orientation} + query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}" + logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}") + + try: + r = requests.get( + query_url, + headers=headers, + proxies=config.proxy, + verify=_get_tls_verify(), + timeout=(30, 60), + ) + response = r.json() + video_items = [] + if "videos" not in response: + logger.error(f"search videos failed: {response}") + return video_items + videos = response["videos"] + # loop through each video in the result + for v in videos: + duration = v["duration"] + # check if video has desired minimum duration + if duration < minimum_duration: + continue + video_files = v["video_files"] + # loop through each url to determine the best quality + for video in video_files: + w = int(video["width"]) + h = int(video["height"]) + if w == video_width and h == video_height: + item = MaterialInfo() + item.provider = "pexels" + item.url = video["link"] + item.duration = duration + video_items.append(item) + break + return video_items + except Exception as e: + logger.error(f"search videos failed: {str(e)}") + + return [] + + +def search_videos_pixabay( + search_term: str, + minimum_duration: int, + video_aspect: VideoAspect = VideoAspect.portrait, +) -> List[MaterialInfo]: + aspect = VideoAspect(video_aspect) + + video_width, video_height = aspect.to_resolution() + + api_key = get_api_key("pixabay_api_keys") + # Build URL + params = { + "q": search_term, + "video_type": "all", # Accepted values: "all", "film", "animation" + "per_page": 50, + "key": api_key, + } + query_url = f"https://pixabay.com/api/videos/?{urlencode(params)}" + logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}") + + try: + r = requests.get( + query_url, proxies=config.proxy, verify=_get_tls_verify(), timeout=(30, 60) + ) + response = r.json() + video_items = [] + if "hits" not in response: + logger.error(f"search videos failed: {response}") + return video_items + videos = response["hits"] + # loop through each video in the result + for v in videos: + duration = v["duration"] + # check if video has desired minimum duration + if duration < minimum_duration: + continue + video_files = v["videos"] + # loop through each url to determine the best quality + for video_type in video_files: + video = video_files[video_type] + w = int(video["width"]) + # h = int(video["height"]) + if w >= video_width: + item = MaterialInfo() + item.provider = "pixabay" + item.url = video["url"] + item.duration = duration + video_items.append(item) + break + return video_items + except Exception as e: + logger.error(f"search videos failed: {str(e)}") + + return [] + + +def save_video(video_url: str, save_dir: str = "") -> str: + if not save_dir: + save_dir = utils.storage_dir("cache_videos") + + if not os.path.exists(save_dir): + os.makedirs(save_dir) + + url_without_query = video_url.split("?")[0] + url_hash = utils.md5(url_without_query) + video_id = f"vid-{url_hash}" + video_path = f"{save_dir}/{video_id}.mp4" + + # if video already exists, return the path + if os.path.exists(video_path) and os.path.getsize(video_path) > 0: + logger.info(f"video already exists: {video_path}") + return video_path + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36" + } + + # if video does not exist, download it + with open(video_path, "wb") as f: + f.write( + requests.get( + video_url, + headers=headers, + proxies=config.proxy, + verify=_get_tls_verify(), + timeout=(60, 240), + ).content + ) + + if os.path.exists(video_path) and os.path.getsize(video_path) > 0: + clip = None + try: + clip = VideoFileClip(video_path) + duration = clip.duration + fps = clip.fps + if duration > 0 and fps > 0: + return video_path + except Exception as e: + logger.warning(f"invalid video file: {video_path} => {str(e)}") + try: + os.remove(video_path) + except Exception as remove_error: + logger.warning( + f"failed to remove invalid video file: {video_path}, error: {str(remove_error)}" + ) + finally: + if clip is not None: + try: + clip.close() + except Exception as close_error: + logger.warning( + f"failed to close video clip: {video_path}, error: {str(close_error)}" + ) + return "" + + +def download_videos( + task_id: str, + search_terms: List[str], + source: str = "pexels", + video_aspect: VideoAspect = VideoAspect.portrait, + video_contact_mode: VideoConcatMode = VideoConcatMode.random, + audio_duration: float = 0.0, + max_clip_duration: int = 5, +) -> List[str]: + valid_video_items = [] + valid_video_urls = [] + found_duration = 0.0 + search_videos = search_videos_pexels + if source == "pixabay": + search_videos = search_videos_pixabay + + for search_term in search_terms: + video_items = search_videos( + search_term=search_term, + minimum_duration=max_clip_duration, + video_aspect=video_aspect, + ) + logger.info(f"found {len(video_items)} videos for '{search_term}'") + + for item in video_items: + if item.url not in valid_video_urls: + valid_video_items.append(item) + valid_video_urls.append(item.url) + found_duration += item.duration + + logger.info( + f"found total videos: {len(valid_video_items)}, required duration: {audio_duration} seconds, found duration: {found_duration} seconds" + ) + video_paths = [] + + material_directory = config.app.get("material_directory", "").strip() + if material_directory == "task": + material_directory = utils.task_dir(task_id) + elif material_directory and not os.path.isdir(material_directory): + material_directory = "" + + concat_mode_value = getattr(video_contact_mode, "value", video_contact_mode) + if concat_mode_value == VideoConcatMode.random.value: + random.shuffle(valid_video_items) + + total_duration = 0.0 + for item in valid_video_items: + try: + logger.info(f"downloading video: {item.url}") + saved_video_path = save_video( + video_url=item.url, save_dir=material_directory + ) + if saved_video_path: + logger.info(f"video saved: {saved_video_path}") + video_paths.append(saved_video_path) + seconds = min(max_clip_duration, item.duration) + total_duration += seconds + if total_duration > audio_duration: + logger.info( + f"total duration of downloaded videos: {total_duration} seconds, skip downloading more" + ) + break + except Exception as e: + logger.error(f"failed to download video: {utils.to_json(item)} => {str(e)}") + logger.success(f"downloaded {len(video_paths)} videos") + return video_paths + + +if __name__ == "__main__": + download_videos( + "test123", ["Money Exchange Medium"], audio_duration=100, source="pixabay" + ) diff --git a/app/services/state.py b/app/services/state.py new file mode 100644 index 0000000..3e6b98d --- /dev/null +++ b/app/services/state.py @@ -0,0 +1,168 @@ +import ast +from abc import ABC, abstractmethod + +from app.config import config +from app.models import const + + +# Base class for state management +class BaseState(ABC): + @abstractmethod + def update_task(self, task_id: str, state: int, progress: int = 0, **kwargs): + pass + + @abstractmethod + def get_task(self, task_id: str): + pass + + @abstractmethod + def get_all_tasks(self, page: int, page_size: int): + pass + + +# Memory state management +class MemoryState(BaseState): + def __init__(self): + self._tasks = {} + + def get_all_tasks(self, page: int, page_size: int): + start = (page - 1) * page_size + end = start + page_size + tasks = list(self._tasks.values()) + total = len(tasks) + return tasks[start:end], total + + def update_task( + self, + task_id: str, + state: int = const.TASK_STATE_PROCESSING, + progress: int = 0, + **kwargs, + ): + progress = int(progress) + if progress > 100: + progress = 100 + + self._tasks[task_id] = { + "task_id": task_id, + "state": state, + "progress": progress, + **kwargs, + } + + def get_task(self, task_id: str): + return self._tasks.get(task_id, None) + + def delete_task(self, task_id: str): + if task_id in self._tasks: + del self._tasks[task_id] + + +# Redis state management +class RedisState(BaseState): + def __init__(self, host="localhost", port=6379, db=0, password=None): + import redis + + self._redis = redis.StrictRedis(host=host, port=port, db=db, password=password) + + def get_all_tasks(self, page: int, page_size: int): + start = (page - 1) * page_size + end = start + page_size + tasks = [] + cursor = 0 + total = 0 + while True: + cursor, keys = self._redis.scan(cursor, count=page_size) + batch_start = total + batch_size = len(keys) + total += batch_size + + # Redis SCAN 是分批返回 key。分页切片必须基于“当前批次起始索引” + # 计算,而不能用累积后的 total 反推,否则第一页会切到空数组, + # 第二页也可能只返回部分数据。 + if batch_start < end and total > start: + slice_start = max(0, start - batch_start) + slice_end = min(batch_size, end - batch_start) + for key in keys[slice_start:slice_end]: + task_data = self._redis.hgetall(key) + task = { + k.decode("utf-8"): self._convert_to_original_type(v) + for k, v in task_data.items() + } + tasks.append(task) + + # 即使当前页已经取满,也要继续 SCAN 到 cursor=0, + # 因为调用方需要准确 total 来渲染分页信息。 + if cursor == 0: + break + return tasks, total + + def update_task( + self, + task_id: str, + state: int = const.TASK_STATE_PROCESSING, + progress: int = 0, + **kwargs, + ): + progress = int(progress) + if progress > 100: + progress = 100 + + fields = { + "task_id": task_id, + "state": state, + "progress": progress, + **kwargs, + } + + for field, value in fields.items(): + self._redis.hset(task_id, field, str(value)) + + def get_task(self, task_id: str): + task_data = self._redis.hgetall(task_id) + if not task_data: + return None + + task = { + key.decode("utf-8"): self._convert_to_original_type(value) + for key, value in task_data.items() + } + return task + + def delete_task(self, task_id: str): + self._redis.delete(task_id) + + @staticmethod + def _convert_to_original_type(value): + """ + Convert the value from byte string to its original data type. + You can extend this method to handle other data types as needed. + """ + value_str = value.decode("utf-8") + + try: + # try to convert byte string array to list + return ast.literal_eval(value_str) + except (ValueError, SyntaxError): + pass + + if value_str.isdigit(): + return int(value_str) + # Add more conversions here if needed + return value_str + + +# Global state +_enable_redis = config.app.get("enable_redis", False) +_redis_host = config.app.get("redis_host", "localhost") +_redis_port = config.app.get("redis_port", 6379) +_redis_db = config.app.get("redis_db", 0) +_redis_password = config.app.get("redis_password", None) + +state = ( + RedisState( + host=_redis_host, port=_redis_port, db=_redis_db, password=_redis_password + ) + if _enable_redis + else MemoryState() +) diff --git a/app/services/subtitle.py b/app/services/subtitle.py new file mode 100644 index 0000000..9c39a8c --- /dev/null +++ b/app/services/subtitle.py @@ -0,0 +1,305 @@ +import json +import os.path +import re +from timeit import default_timer as timer + +try: + from faster_whisper import WhisperModel +except ImportError: + WhisperModel = None +from loguru import logger + +from app.config import config +from app.utils import utils + +model_size = config.whisper.get("model_size", "large-v3") +device = config.whisper.get("device", "cpu") +compute_type = config.whisper.get("compute_type", "int8") +model = None + + +def create(audio_file, subtitle_file: str = ""): + global model + if WhisperModel is None: + logger.warning("faster_whisper not available, skipping whisper subtitle generation") + return "" + if not model: + model_path = f"{utils.root_dir()}/models/whisper-{model_size}" + model_bin_file = f"{model_path}/model.bin" + if not os.path.isdir(model_path) or not os.path.isfile(model_bin_file): + model_path = model_size + + logger.info( + f"loading model: {model_path}, device: {device}, compute_type: {compute_type}" + ) + try: + model = WhisperModel( + model_size_or_path=model_path, device=device, compute_type=compute_type + ) + except Exception as e: + logger.error( + f"failed to load model: {e} \n\n" + f"********************************************\n" + f"this may be caused by network issue. \n" + f"please download the model manually and put it in the 'models' folder. \n" + f"see [README.md FAQ](https://github.com/harry0703/MoneyPrinterTurbo) for more details.\n" + f"********************************************\n\n" + ) + return None + + logger.info(f"start, output file: {subtitle_file}") + if not subtitle_file: + subtitle_file = f"{audio_file}.srt" + + segments, info = model.transcribe( + audio_file, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters=dict(min_silence_duration_ms=500), + ) + + logger.info( + f"detected language: '{info.language}', probability: {info.language_probability:.2f}" + ) + + start = timer() + subtitles = [] + + def recognized(seg_text, seg_start, seg_end): + seg_text = seg_text.strip() + if not seg_text: + return + + msg = "[%.2fs -> %.2fs] %s" % (seg_start, seg_end, seg_text) + logger.debug(msg) + + subtitles.append( + {"msg": seg_text, "start_time": seg_start, "end_time": seg_end} + ) + + for segment in segments: + words_idx = 0 + words_len = len(segment.words) + + seg_start = 0 + seg_end = 0 + seg_text = "" + + if segment.words: + is_segmented = False + for word in segment.words: + if not is_segmented: + seg_start = word.start + is_segmented = True + + seg_end = word.end + # If it contains punctuation, then break the sentence. + seg_text += word.word + + if utils.str_contains_punctuation(word.word): + # remove last char + seg_text = seg_text[:-1] + if not seg_text: + continue + + recognized(seg_text, seg_start, seg_end) + + is_segmented = False + seg_text = "" + + if words_idx == 0 and segment.start < word.start: + seg_start = word.start + if words_idx == (words_len - 1) and segment.end > word.end: + seg_end = word.end + words_idx += 1 + + if not seg_text: + continue + + recognized(seg_text, seg_start, seg_end) + + end = timer() + + diff = end - start + logger.info(f"complete, elapsed: {diff:.2f} s") + + idx = 1 + lines = [] + for subtitle in subtitles: + text = subtitle.get("msg") + if text: + lines.append( + utils.text_to_srt( + idx, text, subtitle.get("start_time"), subtitle.get("end_time") + ) + ) + idx += 1 + + sub = "\n".join(lines) + "\n" + with open(subtitle_file, "w", encoding="utf-8") as f: + f.write(sub) + logger.info(f"subtitle file created: {subtitle_file}") + + +def file_to_subtitles(filename): + if not filename or not os.path.isfile(filename): + return [] + + times_texts = [] + current_times = None + current_text = "" + index = 0 + with open(filename, "r", encoding="utf-8") as f: + for line in f: + times = re.findall("([0-9]*:[0-9]*:[0-9]*,[0-9]*)", line) + if times: + current_times = line + elif line.strip() == "" and current_times: + index += 1 + times_texts.append((index, current_times.strip(), current_text.strip())) + current_times, current_text = None, "" + elif current_times: + current_text += line + return times_texts + + +def levenshtein_distance(s1, s2): + if len(s1) < len(s2): + return levenshtein_distance(s2, s1) + + if len(s2) == 0: + return len(s1) + + previous_row = range(len(s2) + 1) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + + return previous_row[-1] + + +def similarity(a, b): + distance = levenshtein_distance(a.lower(), b.lower()) + max_length = max(len(a), len(b)) + return 1 - (distance / max_length) + + +def correct(subtitle_file, video_script): + subtitle_items = file_to_subtitles(subtitle_file) + script_lines = utils.split_string_by_punctuations(video_script) + + corrected = False + new_subtitle_items = [] + script_index = 0 + subtitle_index = 0 + + while script_index < len(script_lines) and subtitle_index < len(subtitle_items): + script_line = script_lines[script_index].strip() + subtitle_line = subtitle_items[subtitle_index][2].strip() + + if script_line == subtitle_line: + new_subtitle_items.append(subtitle_items[subtitle_index]) + script_index += 1 + subtitle_index += 1 + else: + combined_subtitle = subtitle_line + start_time = subtitle_items[subtitle_index][1].split(" --> ")[0] + end_time = subtitle_items[subtitle_index][1].split(" --> ")[1] + next_subtitle_index = subtitle_index + 1 + + while next_subtitle_index < len(subtitle_items): + next_subtitle = subtitle_items[next_subtitle_index][2].strip() + if similarity( + script_line, combined_subtitle + " " + next_subtitle + ) > similarity(script_line, combined_subtitle): + combined_subtitle += " " + next_subtitle + end_time = subtitle_items[next_subtitle_index][1].split(" --> ")[1] + next_subtitle_index += 1 + else: + break + + if similarity(script_line, combined_subtitle) > 0.8: + logger.warning( + f"Merged/Corrected - Script: {script_line}, Subtitle: {combined_subtitle}" + ) + new_subtitle_items.append( + ( + len(new_subtitle_items) + 1, + f"{start_time} --> {end_time}", + script_line, + ) + ) + corrected = True + else: + logger.warning( + f"Mismatch - Script: {script_line}, Subtitle: {combined_subtitle}" + ) + new_subtitle_items.append( + ( + len(new_subtitle_items) + 1, + f"{start_time} --> {end_time}", + script_line, + ) + ) + corrected = True + + script_index += 1 + subtitle_index = next_subtitle_index + + # Process the remaining lines of the script. + while script_index < len(script_lines): + logger.warning(f"Extra script line: {script_lines[script_index]}") + if subtitle_index < len(subtitle_items): + new_subtitle_items.append( + ( + len(new_subtitle_items) + 1, + subtitle_items[subtitle_index][1], + script_lines[script_index], + ) + ) + subtitle_index += 1 + else: + new_subtitle_items.append( + ( + len(new_subtitle_items) + 1, + "00:00:00,000 --> 00:00:00,000", + script_lines[script_index], + ) + ) + script_index += 1 + corrected = True + + if corrected: + with open(subtitle_file, "w", encoding="utf-8") as fd: + for i, item in enumerate(new_subtitle_items): + fd.write(f"{i + 1}\n{item[1]}\n{item[2]}\n\n") + logger.info("Subtitle corrected") + else: + logger.success("Subtitle is correct") + + +if __name__ == "__main__": + task_id = "c12fd1e6-4b0a-4d65-a075-c87abe35a072" + task_dir = utils.task_dir(task_id) + subtitle_file = f"{task_dir}/subtitle.srt" + audio_file = f"{task_dir}/audio.mp3" + + subtitles = file_to_subtitles(subtitle_file) + print(subtitles) + + script_file = f"{task_dir}/script.json" + with open(script_file, "r") as f: + script_content = f.read() + s = json.loads(script_content) + script = s.get("script") + + correct(subtitle_file, script) + + subtitle_file = f"{task_dir}/subtitle-test.srt" + create(audio_file, subtitle_file) diff --git a/app/services/task.py b/app/services/task.py new file mode 100644 index 0000000..a7ad397 --- /dev/null +++ b/app/services/task.py @@ -0,0 +1,400 @@ +import math +import os.path +import re +from os import path + +from loguru import logger + +from app.config import config +from app.models import const +from app.models.schema import VideoConcatMode, VideoParams +from app.services import llm, material, subtitle, video, voice, upload_post +from app.services import state as sm +from app.utils import utils + + +def generate_script(task_id, params): + logger.info("\n\n## generating video script") + video_script = params.video_script.strip() + if not video_script: + video_script = llm.generate_script( + video_subject=params.video_subject, + language=params.video_language, + paragraph_number=params.paragraph_number, + video_script_prompt=params.video_script_prompt, + custom_system_prompt=params.custom_system_prompt, + ) + else: + logger.debug(f"video script: \n{video_script}") + + if not video_script: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + logger.error("failed to generate video script.") + return None + + return video_script + + +def generate_terms(task_id, params, video_script): + logger.info("\n\n## generating video terms") + video_terms = params.video_terms + if not video_terms: + video_terms = llm.generate_terms( + video_subject=params.video_subject, video_script=video_script, amount=5 + ) + else: + if isinstance(video_terms, str): + video_terms = [term.strip() for term in re.split(r"[,,]", video_terms)] + elif isinstance(video_terms, list): + video_terms = [term.strip() for term in video_terms] + else: + raise ValueError("video_terms must be a string or a list of strings.") + + logger.debug(f"video terms: {utils.to_json(video_terms)}") + + if not video_terms: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + logger.error("failed to generate video terms.") + return None + + return video_terms + + +def save_script_data(task_id, video_script, video_terms, params): + script_file = path.join(utils.task_dir(task_id), "script.json") + script_data = { + "script": video_script, + "search_terms": video_terms, + "params": params, + } + + with open(script_file, "w", encoding="utf-8") as f: + f.write(utils.to_json(script_data)) + + +def generate_audio(task_id, params, video_script): + ''' + Generate audio for the video script. + If a custom audio file is provided, it will be used directly. + There will be no subtitle maker object returned in this case. + Otherwise, TTS will be used to generate the audio. + Returns: + - audio_file: path to the generated or provided audio file + - audio_duration: duration of the audio in seconds + - sub_maker: subtitle maker object if TTS is used, None otherwise + ''' + logger.info("\n\n## generating audio") + # /audio 和 /subtitle 请求模型不包含 custom_audio_file, + # 这里统一做兼容读取,避免直调接口时抛属性错误。 + custom_audio_file = getattr(params, "custom_audio_file", None) + if not custom_audio_file or not os.path.exists(custom_audio_file): + if custom_audio_file: + logger.warning( + f"custom audio file not found: {custom_audio_file}, using TTS to generate audio." + ) + else: + logger.info("no custom audio file provided, using TTS to generate audio.") + audio_file = path.join(utils.task_dir(task_id), "audio.mp3") + sub_maker = voice.tts( + text=video_script, + voice_name=voice.parse_voice_name(params.voice_name), + voice_rate=params.voice_rate, + voice_file=audio_file, + ) + if sub_maker is None: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + logger.error( + """failed to generate audio: +1. check if the language of the voice matches the language of the video script. +2. check if the network is available. If you are in China, it is recommended to use a VPN and enable the global traffic mode. + """.strip() + ) + return None, None, None + audio_duration = math.ceil(voice.get_audio_duration(sub_maker)) + if audio_duration == 0: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + logger.error("failed to get audio duration.") + return None, None, None + return audio_file, audio_duration, sub_maker + else: + logger.info(f"using custom audio file: {custom_audio_file}") + audio_duration = voice.get_audio_duration(custom_audio_file) + if audio_duration == 0: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + logger.error("failed to get audio duration from custom audio file.") + return None, None, None + return custom_audio_file, audio_duration, None + +def generate_subtitle(task_id, params, video_script, sub_maker, audio_file): + ''' + Generate subtitle for the video script. + If subtitle generation is disabled or no subtitle maker is provided, it will return an empty string. + Otherwise, it will generate the subtitle using the specified provider. + Returns: + - subtitle_path: path to the generated subtitle file + ''' + logger.info("\n\n## generating subtitle") + if not params.subtitle_enabled or sub_maker is None: + return "" + + subtitle_path = path.join(utils.task_dir(task_id), "subtitle.srt") + subtitle_provider = config.app.get("subtitle_provider", "edge").strip().lower() + logger.info(f"\n\n## generating subtitle, provider: {subtitle_provider}") + + subtitle_fallback = False + if subtitle_provider == "edge": + voice.create_subtitle( + text=video_script, sub_maker=sub_maker, subtitle_file=subtitle_path + ) + if not os.path.exists(subtitle_path): + subtitle_fallback = True + logger.warning("subtitle file not found, fallback to whisper") + + if subtitle_provider == "whisper" or subtitle_fallback: + subtitle.create(audio_file=audio_file, subtitle_file=subtitle_path) + logger.info("\n\n## correcting subtitle") + subtitle.correct(subtitle_file=subtitle_path, video_script=video_script) + + subtitle_lines = subtitle.file_to_subtitles(subtitle_path) + if not subtitle_lines: + logger.warning(f"subtitle file is invalid: {subtitle_path}") + return "" + + return subtitle_path + + +def get_video_materials(task_id, params, video_terms, audio_duration): + if params.video_source == "local": + logger.info("\n\n## preprocess local materials") + materials = video.preprocess_video( + materials=params.video_materials, clip_duration=params.video_clip_duration + ) + if not materials: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + logger.error( + "no valid materials found, please check the materials and try again." + ) + return None + + return [material_info.url for material_info in materials] + else: + logger.info(f"\n\n## downloading videos from {params.video_source}") + downloaded_videos = material.download_videos( + task_id=task_id, + search_terms=video_terms, + source=params.video_source, + video_aspect=params.video_aspect, + video_contact_mode=params.video_concat_mode, + audio_duration=audio_duration * params.video_count, + max_clip_duration=params.video_clip_duration, + ) + if not downloaded_videos: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + logger.error( + "failed to download videos, maybe the network is not available. if you are in China, please use a VPN." + ) + return None + return downloaded_videos + + +def generate_final_videos( + task_id, params, downloaded_videos, audio_file, subtitle_path +): + final_video_paths = [] + combined_video_paths = [] + video_concat_mode = ( + params.video_concat_mode if params.video_count == 1 else VideoConcatMode.random + ) + video_transition_mode = params.video_transition_mode + + _progress = 50 + for i in range(params.video_count): + index = i + 1 + combined_video_path = path.join( + utils.task_dir(task_id), f"combined-{index}.mp4" + ) + logger.info(f"\n\n## combining video: {index} => {combined_video_path}") + video.combine_videos( + combined_video_path=combined_video_path, + video_paths=downloaded_videos, + audio_file=audio_file, + video_aspect=params.video_aspect, + video_concat_mode=video_concat_mode, + video_transition_mode=video_transition_mode, + max_clip_duration=params.video_clip_duration, + threads=params.n_threads, + ) + + _progress += 50 / params.video_count / 2 + sm.state.update_task(task_id, progress=_progress) + + final_video_path = path.join(utils.task_dir(task_id), f"final-{index}.mp4") + + logger.info(f"\n\n## generating video: {index} => {final_video_path}") + video.generate_video( + video_path=combined_video_path, + audio_path=audio_file, + subtitle_path=subtitle_path, + output_file=final_video_path, + params=params, + ) + + _progress += 50 / params.video_count / 2 + sm.state.update_task(task_id, progress=_progress) + + final_video_paths.append(final_video_path) + combined_video_paths.append(combined_video_path) + + return final_video_paths, combined_video_paths + + +def start(task_id, params: VideoParams, stop_at: str = "video"): + logger.info(f"params:{params}") + logger.info(f"start task: {task_id}, stop_at: {stop_at}") + sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=5) + + # 1. Generate script + video_script = generate_script(task_id, params) + if not video_script or "Error: " in video_script: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + return + + sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=10) + + if stop_at == "script": + sm.state.update_task( + task_id, state=const.TASK_STATE_COMPLETE, progress=100, script=video_script + ) + return {"script": video_script} + + # 2. Generate terms + video_terms = "" + if params.video_source != "local": + video_terms = generate_terms(task_id, params, video_script) + if not video_terms: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + return + + save_script_data(task_id, video_script, video_terms, params) + + if stop_at == "terms": + sm.state.update_task( + task_id, state=const.TASK_STATE_COMPLETE, progress=100, terms=video_terms + ) + return {"script": video_script, "terms": video_terms} + + sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=20) + + # 3. Generate audio + audio_file, audio_duration, sub_maker = generate_audio( + task_id, params, video_script + ) + if not audio_file: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + return + + sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=30) + + if stop_at == "audio": + sm.state.update_task( + task_id, + state=const.TASK_STATE_COMPLETE, + progress=100, + audio_file=audio_file, + ) + return {"audio_file": audio_file, "audio_duration": audio_duration} + + # 4. Generate subtitle + subtitle_path = generate_subtitle( + task_id, params, video_script, sub_maker, audio_file + ) + + if stop_at == "subtitle": + sm.state.update_task( + task_id, + state=const.TASK_STATE_COMPLETE, + progress=100, + subtitle_path=subtitle_path, + ) + return {"subtitle_path": subtitle_path} + + sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=40) + + # 5. Get video materials + downloaded_videos = get_video_materials( + task_id, params, video_terms, audio_duration + ) + if not downloaded_videos: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + return + + if stop_at == "materials": + sm.state.update_task( + task_id, + state=const.TASK_STATE_COMPLETE, + progress=100, + materials=downloaded_videos, + ) + return {"materials": downloaded_videos} + + sm.state.update_task(task_id, state=const.TASK_STATE_PROCESSING, progress=50) + + # 仅完整视频生成流程才需要处理视频拼接模式; + # 这样可以避免 /subtitle 和 /audio 这类请求访问不存在的字段。 + if type(params.video_concat_mode) is str: + params.video_concat_mode = VideoConcatMode(params.video_concat_mode) + + # 6. Generate final videos + logger.info(f"\n\n## cross-posting videos to TikTok/Instagram{task_id}, {params}, {downloaded_videos}, {audio_file}, {subtitle_path}") + final_video_paths, combined_video_paths = generate_final_videos( + task_id, params, downloaded_videos, audio_file, subtitle_path + ) + + if not final_video_paths: + sm.state.update_task(task_id, state=const.TASK_STATE_FAILED) + return + + logger.success( + f"task {task_id} finished, generated {len(final_video_paths)} videos." + ) + + # 7. Cross-post to TikTok/Instagram (if enabled) + cross_post_results = [] + if upload_post.upload_post_service.is_configured() and upload_post.upload_post_service.auto_upload: + logger.info("\n\n## cross-posting videos to TikTok/Instagram") + for video_path in final_video_paths: + result = upload_post.cross_post_video( + video_path=video_path, + title=params.video_subject or "Check out this video! #shorts #viral" + ) + cross_post_results.append(result) + if result.get('success'): + logger.info(f"✅ Cross-posted: {video_path}") + else: + logger.warning(f"⚠️ Failed to cross-post: {video_path} - {result.get('error', 'Unknown error')}") + + kwargs = { + "videos": final_video_paths, + "combined_videos": combined_video_paths, + "script": video_script, + "terms": video_terms, + "audio_file": audio_file, + "audio_duration": audio_duration, + "subtitle_path": subtitle_path, + "materials": downloaded_videos, + "cross_post_results": cross_post_results if cross_post_results else None, + } + sm.state.update_task( + task_id, state=const.TASK_STATE_COMPLETE, progress=100, **kwargs + ) + return kwargs + + +if __name__ == "__main__": + task_id = "task_id" + params = VideoParams( + video_subject="金钱的作用", + voice_name="zh-CN-XiaoyiNeural-Female", + voice_rate=1.0, + ) + start(task_id, params, stop_at="video") diff --git a/app/services/upload_post.py b/app/services/upload_post.py new file mode 100644 index 0000000..2c0e710 --- /dev/null +++ b/app/services/upload_post.py @@ -0,0 +1,147 @@ +""" +Upload-Post API integration for cross-posting videos to TikTok and Instagram. + +Docs: https://docs.upload-post.com +""" +import os +import requests +from loguru import logger +from app.config import config + + +class UploadPostService: + """ + Service for cross-posting videos to TikTok/Instagram via Upload-Post API. + """ + + API_BASE = "https://api.upload-post.com" + + def __init__(self): + self.api_key = config.app.get("upload_post_api_key", "") + self.username = config.app.get("upload_post_username", "") + self.enabled = config.app.get("upload_post_enabled", False) + self.platforms = config.app.get("upload_post_platforms", ["tiktok", "instagram"]) + self.auto_upload = config.app.get("upload_post_auto_upload", False) + + def is_configured(self) -> bool: + """Check if Upload-Post is properly configured.""" + return bool(self.api_key and self.username and self.enabled) + + def upload_video( + self, + video_path: str, + title: str, + platforms: list = None, + privacy_level: str = "PUBLIC_TO_EVERYONE" + ) -> dict: + """ + Upload a video to TikTok and/or Instagram. + + Args: + video_path (str): Path to the video file + title (str): Video title/caption (max 2200 chars for Instagram) + platforms (list): List of platforms ["tiktok", "instagram"] + privacy_level (str): Privacy level for the video + + Returns: + dict: API response with request_id and status + """ + if not self.is_configured(): + logger.warning("Upload-Post is not configured. Skipping cross-post.") + return {"success": False, "error": "Upload-Post not configured"} + + if platforms is None: + platforms = self.platforms + + if not os.path.exists(video_path): + logger.error(f"Video file not found: {video_path}") + return {"success": False, "error": f"Video file not found: {video_path}"} + + logger.info(f"Cross-posting video to {', '.join(platforms)} via Upload-Post...") + + try: + with open(video_path, 'rb') as video_file: + files = {'video': video_file} + + data = { + 'user': self.username, + 'title': title[:2200], + 'privacy_level': privacy_level + } + + # Add each platform + for i, platform in enumerate(platforms): + data[f'platform[{i}]'] = platform + + headers = { + 'Authorization': f'Apikey {self.api_key}' + } + + response = requests.post( + f"{self.API_BASE}/api/upload_video", + headers=headers, + data=data, + files=files, + timeout=300 + ) + + response.raise_for_status() + result = response.json() + + if result.get('success'): + logger.info(f"✅ Video cross-posted successfully! Request ID: {result.get('request_id')}") + else: + logger.warning(f"Cross-post failed: {result.get('message', 'Unknown error')}") + + return result + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to cross-post video: {str(e)}") + return {"success": False, "error": str(e)} + + def check_status(self, request_id: str) -> dict: + """ + Check the status of an upload request. + + Args: + request_id (str): The request ID from upload + + Returns: + dict: Status information + """ + try: + headers = { + 'Authorization': f'Apikey {self.api_key}' + } + + response = requests.get( + f"{self.API_BASE}/api/status/{request_id}", + headers=headers, + timeout=30 + ) + + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to check status: {str(e)}") + return {"success": False, "error": str(e)} + + +# Singleton instance +upload_post_service = UploadPostService() + + +def cross_post_video(video_path: str, title: str, platforms: list = None) -> dict: + """ + Convenience function to cross-post a video. + + Args: + video_path (str): Path to the video file + title (str): Video title/caption + platforms (list): List of platforms (defaults to config) + + Returns: + dict: API response + """ + return upload_post_service.upload_video(video_path, title, platforms) diff --git a/app/services/utils/video_effects.py b/app/services/utils/video_effects.py new file mode 100644 index 0000000..1a76434 --- /dev/null +++ b/app/services/utils/video_effects.py @@ -0,0 +1,73 @@ +from moviepy import Clip, ColorClip, CompositeVideoClip, vfx + + +# FadeIn +def fadein_transition(clip: Clip, t: float) -> Clip: + return clip.with_effects([vfx.FadeIn(t)]) + + +# FadeOut +def fadeout_transition(clip: Clip, t: float) -> Clip: + return clip.with_effects([vfx.FadeOut(t)]) + + +# SlideIn +def slidein_transition(clip: Clip, t: float, side: str) -> Clip: + width, height = clip.size + + # MoviePy 内置 SlideIn 在当前这条处理链里对全屏素材不稳定, + # 会出现“逻辑上应用了转场,但画面几乎看不出变化”的情况。 + # 这里改成显式黑底 + 位移动画,保证转场效果可见且行为可控。 + def position(current_time: float): + progress = min(max(current_time / max(t, 0.001), 0), 1) + + if side == "left": + return (-width + width * progress, 0) + if side == "right": + return (width - width * progress, 0) + if side == "top": + return (0, -height + height * progress) + if side == "bottom": + return (0, height - height * progress) + return (0, 0) + + background = ColorClip(size=(width, height), color=(0, 0, 0)).with_duration( + clip.duration + ) + moving_clip = clip.with_position(position) + return CompositeVideoClip([background, moving_clip], size=(width, height)).with_duration( + clip.duration + ) + + +# SlideOut +def slideout_transition(clip: Clip, t: float, side: str) -> Clip: + width, height = clip.size + transition_start = max(clip.duration - t, 0) + + # SlideOut 同样改成显式位移,保证片段末尾能稳定滑出画面。 + def position(current_time: float): + if current_time <= transition_start: + return (0, 0) + + progress = min( + max((current_time - transition_start) / max(t, 0.001), 0), 1 + ) + + if side == "left": + return (-width * progress, 0) + if side == "right": + return (width * progress, 0) + if side == "top": + return (0, -height * progress) + if side == "bottom": + return (0, height * progress) + return (0, 0) + + background = ColorClip(size=(width, height), color=(0, 0, 0)).with_duration( + clip.duration + ) + moving_clip = clip.with_position(position) + return CompositeVideoClip([background, moving_clip], size=(width, height)).with_duration( + clip.duration + ) diff --git a/app/services/video.py b/app/services/video.py new file mode 100644 index 0000000..460d722 --- /dev/null +++ b/app/services/video.py @@ -0,0 +1,789 @@ +import gc +import glob +import io +import itertools +import os +import random +import shutil +import subprocess +from contextlib import redirect_stdout +from typing import List + +from PIL import Image, ImageFont +from loguru import logger +from moviepy import ( + AudioFileClip, + ColorClip, + CompositeAudioClip, + CompositeVideoClip, + ImageClip, + TextClip, + VideoFileClip, + afx, +) +from moviepy.video.tools.subtitles import SubtitlesClip + +from app.models import const +from app.models.schema import ( + MaterialInfo, + VideoAspect, + VideoConcatMode, + VideoParams, + VideoTransitionMode, +) +from app.services.utils import video_effects +from app.utils import file_security, utils + + +class SubClippedVideoClip: + def __init__(self, file_path, start_time=None, end_time=None, width=None, height=None, duration=None): + self.file_path = file_path + self.start_time = start_time + self.end_time = end_time + self.width = width + self.height = height + if duration is None: + self.duration = end_time - start_time + else: + self.duration = duration + + def __str__(self): + return f"SubClippedVideoClip(file_path={self.file_path}, start_time={self.start_time}, end_time={self.end_time}, duration={self.duration}, width={self.width}, height={self.height})" + + +audio_codec = "aac" +# Docker 里的 ffmpeg/AAC 组合在默认配置下更容易出现音频质量波动, +# 这里显式抬高音频码率,避免成片阶段因为默认值过低而引入明显失真。 +audio_bitrate = "192k" +video_codec = "libx264" +fps = 30 +_BGM_EXTENSIONS = (".mp3",) + + +def get_ffmpeg_binary(): + # 优先复用用户在 config.toml / 环境变量里显式指定的 ffmpeg,可避免 + # Windows 便携包、Docker、自定义安装目录等场景下 PATH 不一致。 + configured_ffmpeg = os.environ.get("IMAGEIO_FFMPEG_EXE") + if configured_ffmpeg: + return configured_ffmpeg + + system_ffmpeg = shutil.which("ffmpeg") + if system_ffmpeg: + return system_ffmpeg + + try: + import imageio_ffmpeg + + bundled_ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() + if bundled_ffmpeg: + return bundled_ffmpeg + except Exception as exc: + logger.warning(f"failed to resolve bundled ffmpeg binary: {str(exc)}") + + return "ffmpeg" + + +def _escape_ffmpeg_concat_path(file_path: str) -> str: + # concat demuxer 使用单引号包裹路径,路径中的单引号需要先转义。 + return file_path.replace("'", "'\\''") + + +def concat_video_clips_with_ffmpeg( + clip_files: List[str], output_file: str, threads: int, output_dir: str +): + concat_list_file = os.path.join(output_dir, "ffmpeg-concat-list.txt") + with open(concat_list_file, "w", encoding="utf-8") as fp: + for clip_file in clip_files: + absolute_path = os.path.abspath(clip_file) + fp.write(f"file '{_escape_ffmpeg_concat_path(absolute_path)}'\n") + + command = [ + get_ffmpeg_binary(), + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + concat_list_file, + "-c:v", + video_codec, + "-threads", + str(threads or 2), + "-pix_fmt", + "yuv420p", + output_file, + ] + + try: + # 使用 ffmpeg 只做一次串联与编码,避免 MoviePy 逐段合并时反复重编码, + # 从而降低画质劣化与颜色偏移风险。 + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + error_message = (result.stderr or result.stdout or "").strip() + raise RuntimeError(error_message or "ffmpeg concat failed") + finally: + delete_files(concat_list_file) + + +def _sanitize_image_file(image_path: str) -> str: + # 某些本地图片虽然能被 Pillow 打开,但会因为损坏的 EXIF/eXIf 元数据导致 + # ImageClip 在解析阶段直接抛异常。这里重新导出一份“干净图片”,把坏元数据剥离掉。 + image_root, _ = os.path.splitext(image_path) + sanitized_path = f"{image_root}.sanitized.png" + + with Image.open(image_path) as image: + image.load() + # 统一导出为 PNG,避免 JPEG/PNG 不同元数据路径继续把坏块带过去。 + cleaned_image = Image.new(image.mode, image.size) + cleaned_image.putdata(list(image.getdata())) + cleaned_image.save(sanitized_path) + + return sanitized_path + + +def _open_image_clip_with_fallback(image_path: str): + # 优先直接打开原始图片;如果因为损坏元数据失败,再尝试生成无元数据副本。 + try: + return ImageClip(image_path), image_path + except Exception as exc: + logger.warning( + f"failed to open image directly, trying sanitized copy: {image_path}, error: {str(exc)}" + ) + sanitized_path = _sanitize_image_file(image_path) + return ImageClip(sanitized_path), sanitized_path + + +def _open_video_clip_quietly(video_path: str, audio: bool = False) -> VideoFileClip: + """ + 安静地打开视频文件,避免 MoviePy 2.1.x 把 ffmpeg 探测信息直接打印到 stdout。 + + 背景: + 当前依赖版本的 `FFMPEG_VideoReader` 内部存在 `print(self.infos)` 和 + `print(ffmpeg command)`,读取无音轨的中间视频时会输出 + `audio_found: False`。这只是输入素材 metadata,不代表最终成片没有音频, + 但会误导 WebUI/终端用户以为生成失败。 + + 实现: + 1. 只在打开 VideoFileClip 的短窗口内重定向 stdout; + 2. 默认 `audio=False`,因为项目视频素材阶段不需要保留素材原声, + 最终音频会在 `generate_video()` 阶段统一挂载; + 3. 如果依赖库确实输出了内容,降级为 debug 日志,便于必要时排查。 + """ + captured_stdout = io.StringIO() + with redirect_stdout(captured_stdout): + print("video_path", video_path) + clip = VideoFileClip(video_path, audio=audio) + + moviepy_stdout = captured_stdout.getvalue().strip() + if moviepy_stdout: + logger.debug( + "suppressed MoviePy video reader stdout for " + f"{video_path}, chars: {len(moviepy_stdout)}" + ) + + return clip + + +def close_clip(clip): + if clip is None: + return + + try: + # close main resources + if hasattr(clip, 'reader') and clip.reader is not None: + clip.reader.close() + + # close audio resources + if hasattr(clip, 'audio') and clip.audio is not None: + if hasattr(clip.audio, 'reader') and clip.audio.reader is not None: + clip.audio.reader.close() + del clip.audio + + # close mask resources + if hasattr(clip, 'mask') and clip.mask is not None: + if hasattr(clip.mask, 'reader') and clip.mask.reader is not None: + clip.mask.reader.close() + del clip.mask + + # handle child clips in composite clips + if hasattr(clip, 'clips') and clip.clips: + for child_clip in clip.clips: + if child_clip is not clip: # avoid possible circular references + close_clip(child_clip) + + # clear clip list + if hasattr(clip, 'clips'): + clip.clips = [] + + except Exception as e: + logger.error(f"failed to close clip: {str(e)}") + + del clip + gc.collect() + + +def delete_files(files: List[str] | str): + if isinstance(files, str): + files = [files] + + for file in files: + try: + os.remove(file) + except Exception as e: + logger.debug(f"failed to delete file {file}: {str(e)}") + + +def _resolve_bgm_file_path(song_dir: str, bgm_file: str) -> str: + # 背景音乐只允许读取 resource/songs 目录内的文件,避免用户输入任意路径后 + # 被 MoviePy 打开。这里兼容两种常见输入: + # 1. output000.mp3:来自 BGM 列表或用户只填写文件名 + # 2. ./resource/songs/output000.mp3:用户按项目目录结构填写的相对路径 + # 两种写法最终都会再次通过 resource/songs 白名单校验,不能绕过目录限制。 + try: + return file_security.resolve_path_within_directory(song_dir, bgm_file) + except ValueError as song_dir_exc: + if os.path.isabs(bgm_file): + raise song_dir_exc + + project_relative_file = os.path.join(utils.root_dir(), bgm_file) + try: + return file_security.resolve_path_within_directory( + song_dir, project_relative_file + ) + except ValueError as root_dir_exc: + raise ValueError(str(root_dir_exc)) from song_dir_exc + + +def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""): + if not bgm_type: + return "" + + if bgm_file: + song_dir = utils.song_dir() + try: + resolved_bgm_file = _resolve_bgm_file_path(song_dir, bgm_file) + except ValueError as exc: + # API 请求里的 bgm_file 来自用户输入,不能直接把任意绝对路径交给 + # MoviePy 打开。这里强制限制到 resource/songs 目录,阻止读取 + # /etc/passwd、配置文件、密钥等非背景音乐文件。 + logger.warning( + f"reject unsafe bgm file: {bgm_file}, song_dir: {song_dir}, error: {str(exc)}" + ) + return "" + + if not resolved_bgm_file.lower().endswith(_BGM_EXTENSIONS): + logger.warning(f"reject unsupported bgm file extension: {resolved_bgm_file}") + return "" + + return resolved_bgm_file + + if bgm_type == "random": + suffix = "*.mp3" + song_dir = utils.song_dir() + files = glob.glob(os.path.join(song_dir, suffix)) + # 当背景音乐目录为空时,直接回退为“不使用 BGM”,避免 random.choice([]) 抛异常。 + if not files: + logger.warning(f"no bgm files found in song directory: {song_dir}") + return "" + return random.choice(files) + + return "" + + +def combine_videos( + combined_video_path: str, + video_paths: List[str], + audio_file: str, + video_aspect: VideoAspect = VideoAspect.portrait, + video_concat_mode: VideoConcatMode = VideoConcatMode.random, + video_transition_mode: VideoTransitionMode = None, + max_clip_duration: int = 5, + threads: int = 2, +) -> str: + audio_clip = AudioFileClip(audio_file) + try: + # 这里只需要读取旁白音频时长来决定素材视频拼接长度;后续不会再使用 + # audio_clip。读取完成后立即关闭,避免早退或异常路径泄漏文件句柄。 + audio_duration = audio_clip.duration + finally: + close_clip(audio_clip) + logger.info(f"audio duration: {audio_duration} seconds") + logger.info(f"maximum clip duration: {max_clip_duration} seconds") + + # 兼容 API 直接调用时未传转场模式的情况,避免后续访问 .value 时崩溃。 + transition_value = getattr(video_transition_mode, "value", video_transition_mode) + output_dir = os.path.dirname(combined_video_path) + + aspect = VideoAspect(video_aspect) + video_width, video_height = aspect.to_resolution() + + processed_clips = [] + subclipped_items = [] + video_duration = 0 + for video_path in video_paths: + clip = _open_video_clip_quietly(video_path) + clip_duration = clip.duration + clip_w, clip_h = clip.size + close_clip(clip) + + start_time = 0 + + while start_time < clip_duration: + end_time = min(start_time + max_clip_duration, clip_duration) + + # 保留所有有效分段。 + # 这样既不会丢掉“整段视频本身就短于 max_clip_duration”的素材, + # 也不会吞掉长视频最后剩下的一小段尾部内容。 + if end_time > start_time: + subclipped_items.append( + SubClippedVideoClip( + file_path=video_path, + start_time=start_time, + end_time=end_time, + width=clip_w, + height=clip_h, + ) + ) + + start_time = end_time + if video_concat_mode.value == VideoConcatMode.sequential.value: + break + + # random subclipped_items order + if video_concat_mode.value == VideoConcatMode.random.value: + random.shuffle(subclipped_items) + + logger.debug(f"total subclipped items: {len(subclipped_items)}") + + # Add downloaded clips over and over until the duration of the audio (max_duration) has been reached + for i, subclipped_item in enumerate(subclipped_items): + if video_duration > audio_duration: + break + + logger.debug( + f"processing clip {i + 1}: {subclipped_item.width}x{subclipped_item.height}, current duration: {video_duration:.2f}s, remaining: {audio_duration - video_duration:.2f}s") + + try: + clip = _open_video_clip_quietly(subclipped_item.file_path).subclipped( + subclipped_item.start_time, subclipped_item.end_time + ) + clip_duration = clip.duration + # Not all videos are same size, so we need to resize them + clip_w, clip_h = clip.size + if clip_w != video_width or clip_h != video_height: + clip_ratio = clip.w / clip.h + video_ratio = video_width / video_height + logger.debug( + f"resizing clip, source: {clip_w}x{clip_h}, ratio: {clip_ratio:.2f}, target: {video_width}x{video_height}, ratio: {video_ratio:.2f}") + + if clip_ratio == video_ratio: + clip = clip.resized(new_size=(video_width, video_height)) + else: + if clip_ratio > video_ratio: + scale_factor = video_width / clip_w + else: + scale_factor = video_height / clip_h + + new_width = int(clip_w * scale_factor) + new_height = int(clip_h * scale_factor) + + background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration( + clip_duration) + clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center") + clip = CompositeVideoClip([background, clip_resized]) + + shuffle_side = random.choice(["left", "right", "top", "bottom"]) + if transition_value in (None, VideoTransitionMode.none.value): + clip = clip + elif transition_value == VideoTransitionMode.fade_in.value: + clip = video_effects.fadein_transition(clip, 1) + elif transition_value == VideoTransitionMode.fade_out.value: + clip = video_effects.fadeout_transition(clip, 1) + elif transition_value == VideoTransitionMode.slide_in.value: + clip = video_effects.slidein_transition(clip, 1, shuffle_side) + elif transition_value == VideoTransitionMode.slide_out.value: + clip = video_effects.slideout_transition(clip, 1, shuffle_side) + elif transition_value == VideoTransitionMode.shuffle.value: + transition_funcs = [ + lambda c: video_effects.fadein_transition(c, 1), + lambda c: video_effects.fadeout_transition(c, 1), + lambda c: video_effects.slidein_transition(c, 1, shuffle_side), + lambda c: video_effects.slideout_transition(c, 1, shuffle_side), + ] + shuffle_transition = random.choice(transition_funcs) + clip = shuffle_transition(clip) + + if clip.duration > max_clip_duration: + clip = clip.subclipped(0, max_clip_duration) + + # wirte clip to temp file + clip_file = f"{output_dir}/temp-clip-{i + 1}.mp4" + clip.write_videofile(clip_file, logger=None, fps=fps, codec=video_codec) + + # Store clip duration before closing + clip_duration_saved = clip.duration + close_clip(clip) + + processed_clips.append( + SubClippedVideoClip(file_path=clip_file, duration=clip_duration_saved, width=clip_w, height=clip_h)) + video_duration += clip_duration_saved + + except Exception as e: + logger.error(f"failed to process clip: {str(e)}") + + # loop processed clips until the video duration matches or exceeds the audio duration. + if video_duration < audio_duration: + logger.warning( + f"video duration ({video_duration:.2f}s) is shorter than audio duration ({audio_duration:.2f}s), looping clips to match audio length.") + base_clips = processed_clips.copy() + for clip in itertools.cycle(base_clips): + if video_duration >= audio_duration: + break + processed_clips.append(clip) + video_duration += clip.duration + logger.info( + f"video duration: {video_duration:.2f}s, audio duration: {audio_duration:.2f}s, looped {len(processed_clips) - len(base_clips)} clips") + + # merge video clips progressively, avoid loading all videos at once to avoid memory overflow + logger.info("starting clip merging process") + if not processed_clips: + logger.warning("no clips available for merging") + return combined_video_path + + # if there is only one clip, use it directly + if len(processed_clips) == 1: + logger.info("using single clip directly") + shutil.copy(processed_clips[0].file_path, combined_video_path) + delete_files([processed_clips[0].file_path]) + logger.info("video combining completed") + return combined_video_path + + clip_files = [clip.file_path for clip in processed_clips] + logger.info(f"concatenating {len(clip_files)} clips with ffmpeg") + concat_video_clips_with_ffmpeg( + clip_files=clip_files, + output_file=combined_video_path, + threads=threads, + output_dir=output_dir, + ) + + # clean temp files + delete_files(clip_files) + + logger.info("video combining completed") + return combined_video_path + + +def wrap_text(text, max_width, font="Arial", fontsize=60): + # 字幕换行必须在真正创建 TextClip 前完成,否则 MoviePy 只会按原始文本 + # 计算渲染区域。这里用 PIL 按当前字体和字号测量宽度,确保每一行都尽量 + # 控制在视频可用宽度内,避免大字号或中文长句直接溢出画面。 + font = ImageFont.truetype(font, fontsize) + max_width = int(max_width) + + def get_text_size(inner_text): + inner_text = inner_text.strip() + if not inner_text: + return 0, fontsize + left, top, right, bottom = font.getbbox(inner_text) + return right - left, bottom - top + + width, height = get_text_size(text) + if width <= max_width: + return text, height + + def split_long_token(token): + # 当一个 token 本身就超宽时(常见于中文无空格长句,或英文超长单词), + # 退化为字符级拆分。关键点是:检测到 candidate 超宽时,先提交上一个 + # 仍然合法的 current,再把当前字符放入下一行,不能把超宽字符塞回上一行。 + lines = [] + current = "" + for char in token: + candidate = f"{current}{char}" + candidate_width, _ = get_text_size(candidate) + if candidate_width <= max_width or not current: + current = candidate + continue + lines.append(current) + current = char + if current: + lines.append(current) + return lines + + lines = [] + current = "" + words = text.split(" ") + for word in words: + candidate = f"{current} {word}".strip() if current else word + candidate_width, _ = get_text_size(candidate) + if candidate_width <= max_width: + current = candidate + continue + + if current: + lines.append(current) + + word_width, _ = get_text_size(word) + if word_width <= max_width: + current = word + else: + lines.extend(split_long_token(word)) + current = "" + + if current: + lines.append(current) + + result = "\n".join(line.strip() for line in lines if line.strip()).strip() + height = len(lines) * height + return result, height + + +def generate_video( + video_path: str, + audio_path: str, + subtitle_path: str, + output_file: str, + params: VideoParams, +): + aspect = VideoAspect(params.video_aspect) + video_width, video_height = aspect.to_resolution() + + logger.info(f"generating video: {video_width} x {video_height}") + logger.info(f" ① video: {video_path}") + logger.info(f" ② audio: {audio_path}") + logger.info(f" ③ subtitle: {subtitle_path}") + logger.info(f" ④ output: {output_file}") + + # https://github.com/harry0703/MoneyPrinterTurbo/issues/217 + # PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'final-1.mp4.tempTEMP_MPY_wvf_snd.mp3' + # write into the same directory as the output file + output_dir = os.path.dirname(output_file) + + font_path = "" + if params.subtitle_enabled: + if not params.font_name: + params.font_name = "STHeitiMedium.ttc" + font_path = os.path.join(utils.font_dir(), params.font_name) + if os.name == "nt": + font_path = font_path.replace("\\", "/") + + logger.info(f" ⑤ font: {font_path}") + + def resolve_subtitle_background_color(): + # 兼容历史参数:API 里 `text_background_color` 既可能是布尔值, + # 也可能是实际颜色字符串。统一在这里归一化,避免把 True/False + # 直接传给 TextClip 后出现不可预期的渲染结果。 + if isinstance(params.text_background_color, bool): + return "#000000" if params.text_background_color else None + return params.text_background_color + + def create_text_clip(subtitle_item): + params.font_size = int(params.font_size) + params.stroke_width = int(params.stroke_width) + phrase = subtitle_item[1] + max_width = video_width * 0.9 + wrapped_txt, txt_height = wrap_text( + phrase, max_width=max_width, font=font_path, fontsize=params.font_size + ) + interline = int(params.font_size * 0.25) + line_count = wrapped_txt.count("\n") + 1 + vertical_padding = int(params.font_size * 0.35) + # MoviePy 在 `method=label` 下会自动收缩文本框高度,遇到多行字幕、 + # 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入 + # 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕 + # 背景框与文字本身都能完整渲染出来。 + size = ( + int(max_width), + int(txt_height + vertical_padding + (interline * line_count)), + ) + + _clip = TextClip( + text=wrapped_txt, + font=font_path, + font_size=params.font_size, + color=params.text_fore_color, + bg_color=resolve_subtitle_background_color(), + stroke_color=params.stroke_color, + stroke_width=params.stroke_width, + interline=interline, + size=size, + text_align="center", + ) + duration = subtitle_item[0][1] - subtitle_item[0][0] + _clip = _clip.with_start(subtitle_item[0][0]) + _clip = _clip.with_end(subtitle_item[0][1]) + _clip = _clip.with_duration(duration) + if params.subtitle_position == "bottom": + _clip = _clip.with_position(("center", video_height * 0.95 - _clip.h)) + elif params.subtitle_position == "top": + _clip = _clip.with_position(("center", video_height * 0.05)) + elif params.subtitle_position == "custom": + # Ensure the subtitle is fully within the screen bounds + margin = 10 # Additional margin, in pixels + max_y = video_height - _clip.h - margin + min_y = margin + custom_y = (video_height - _clip.h) * (params.custom_position / 100) + custom_y = max( + min_y, min(custom_y, max_y) + ) # Constrain the y value within the valid range + _clip = _clip.with_position(("center", custom_y)) + else: # center + _clip = _clip.with_position(("center", "center")) + return _clip + + video_clip = _open_video_clip_quietly(video_path) + audio_clip = AudioFileClip(audio_path).with_effects( + [afx.MultiplyVolume(params.voice_volume)] + ) + + def make_textclip(text): + return TextClip( + text=text, + font=font_path, + font_size=params.font_size, + ) + + if subtitle_path and os.path.exists(subtitle_path): + sub = SubtitlesClip( + subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip + ) + text_clips = [] + for item in sub.subtitles: + clip = create_text_clip(subtitle_item=item) + text_clips.append(clip) + video_clip = CompositeVideoClip([video_clip, *text_clips]) + + bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file) + if bgm_file: + try: + bgm_clip = AudioFileClip(bgm_file).with_effects( + [ + afx.MultiplyVolume(params.bgm_volume), + afx.AudioFadeOut(3), + afx.AudioLoop(duration=video_clip.duration), + ] + ) + audio_clip = CompositeAudioClip([audio_clip, bgm_clip]) + except Exception as e: + logger.error(f"failed to add bgm: {str(e)}") + + video_clip = video_clip.with_audio(audio_clip) + # 显式沿用输入音频的采样率;如果取不到,再回退到 MoviePy 默认的 44100Hz。 + # 这样可以减少不同运行环境,尤其是 Docker 环境中再次重采样带来的音质波动。 + output_audio_fps = int(getattr(audio_clip, "fps", 0) or 44100) + video_clip.write_videofile( + output_file, + audio_codec=audio_codec, + audio_fps=output_audio_fps, + audio_bitrate=audio_bitrate, + temp_audiofile_path=output_dir, + threads=params.n_threads or 2, + logger=None, + fps=fps, + ) + video_clip.close() + del video_clip + + +def preprocess_video(materials: List[MaterialInfo], clip_duration=4): + # WebUI 在某些二次生成场景下可能传入空素材列表,这里直接返回空结果,避免抛出 NoneType 异常。 + if not materials: + return [] + + # 仅返回通过预处理校验的素材,避免低分辨率图片继续进入后续的视频合成流程。 + valid_materials = [] + local_videos_dir = utils.storage_dir("local_videos", create=True) + + for material in materials: + if not material.url: + continue + + try: + material_source_path = file_security.resolve_path_within_directory( + local_videos_dir, material.url + ) + except ValueError as exc: + # local video_source 的素材路径来自 API 参数,必须限制在专用素材目录。 + # 允许用户传文件名,也兼容历史返回的绝对路径,但不允许逃逸到系统 + # 其他目录,避免任意文件读取或通过 MoviePy 探测本地敏感文件。 + logger.warning( + f"skip unsafe local material: {material.url}, " + f"local_videos_dir: {local_videos_dir}, error: {str(exc)}" + ) + continue + + ext = utils.parse_extension(material_source_path) + try: + # 图片素材直接按图片方式读取,避免先走 VideoFileClip 误判后触发不稳定的回退分支。 + if ext in const.FILE_TYPE_IMAGES: + clip, material_source_path = _open_image_clip_with_fallback( + material_source_path + ) + else: + clip = _open_video_clip_quietly(material_source_path) + except Exception: + # 非标准扩展名或探测失败时再回退到图片模式,兼容历史上直接传本地图片路径的情况。 + try: + clip, material_source_path = _open_image_clip_with_fallback( + material_source_path + ) + except Exception as exc: + logger.warning( + f"skip unreadable local material: {material.url}, error: {str(exc)}" + ) + continue + try: + width = clip.size[0] + height = clip.size[1] + if width < 480 or height < 480: + logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required") + # 探测到低分辨率素材后立即关闭资源,并且不要把该素材返回给后续流程。 + close_clip(clip) + continue + + if ext in const.FILE_TYPE_IMAGES: + logger.info(f"processing image: {material_source_path}") + # 探测尺寸时已经打开过一次素材,这里先释放探测句柄,再重新创建用于导出的图片 clip。 + close_clip(clip) + # Create an image clip and set its duration to 3 seconds + clip = ( + ImageClip(material_source_path) + .with_duration(clip_duration) + .with_position("center") + ) + # Apply a zoom effect using the resize method. + # A lambda function is used to make the zoom effect dynamic over time. + # The zoom effect starts from the original size and gradually scales up to 120%. + # t represents the current time, and clip.duration is the total duration of the clip (3 seconds). + # Note: 1 represents 100% size, so 1.2 represents 120% size. + zoom_clip = clip.resized( + lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration) + ) + + # Optionally, create a composite video clip containing the zoomed clip. + # This is useful when you want to add other elements to the video. + final_clip = CompositeVideoClip([zoom_clip]) + + # Output the video to a file. + video_file = f"{material_source_path}.mp4" + final_clip.write_videofile(video_file, fps=30, logger=None) + close_clip(clip) + close_clip(final_clip) + material.url = video_file + logger.success(f"image processed: {video_file}") + else: + # 普通视频素材只需要读取尺寸做校验,校验完成后立即释放句柄即可。 + close_clip(clip) + except Exception: + close_clip(clip) + raise + + valid_materials.append(material) + + return valid_materials diff --git a/app/services/voice.py b/app/services/voice.py new file mode 100644 index 0000000..0d87378 --- /dev/null +++ b/app/services/voice.py @@ -0,0 +1,2351 @@ +import asyncio +import base64 +import io +import inspect +import math +import os +import queue +import re +import shutil +import threading +import time +from datetime import datetime +from typing import Union +from xml.sax.saxutils import unescape + +import edge_tts +import requests +from edge_tts import SubMaker, submaker +from loguru import logger +from moviepy.video.tools import subtitles +from moviepy.audio.io.AudioFileClip import AudioFileClip +from openai import OpenAI + +from app.config import config +from app.utils import utils + +_DEFAULT_EDGE_TTS_TIMEOUT_SECONDS = 30.0 +_MIMO_DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1" +_MIMO_DEFAULT_TTS_MODEL = "mimo-v2.5-tts" + + +def _configure_pydub_ffmpeg(audio_segment_cls): + configured_ffmpeg = os.environ.get("IMAGEIO_FFMPEG_EXE") or shutil.which("ffmpeg") + if not configured_ffmpeg: + try: + import imageio_ffmpeg + + configured_ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() + except Exception as exc: + logger.warning(f"failed to resolve bundled ffmpeg binary: {str(exc)}") + + if configured_ffmpeg: + audio_segment_cls.converter = configured_ffmpeg + + +def mktimestamp(time_unit: float) -> str: + """ + 将 edge_tts 使用的 100 纳秒时间单位转换为字幕时间戳。 + + edge_tts 7.x 不再导出旧版本里的 `mktimestamp`,但项目里旧字幕链路 + 还需要这个格式化函数来兼容 Azure v2、Gemini、SiliconFlow 这些 + 手工构造的字幕时间轴,因此这里内置一个等价实现。 + """ + hour = math.floor(time_unit / 10**7 / 3600) + minute = math.floor((time_unit / 10**7 / 60) % 60) + seconds = (time_unit / 10**7) % 60 + return f"{hour:02d}:{minute:02d}:{seconds:06.3f}" + + +def get_siliconflow_voices() -> list[str]: + """ + 获取硅基流动的声音列表 + + Returns: + 声音列表,格式为 ["siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex", ...] + """ + # 硅基流动的声音列表和对应的性别(用于显示) + voices_with_gender = [ + ("FunAudioLLM/CosyVoice2-0.5B", "alex", "Male"), + ("FunAudioLLM/CosyVoice2-0.5B", "anna", "Female"), + ("FunAudioLLM/CosyVoice2-0.5B", "bella", "Female"), + ("FunAudioLLM/CosyVoice2-0.5B", "benjamin", "Male"), + ("FunAudioLLM/CosyVoice2-0.5B", "charles", "Male"), + ("FunAudioLLM/CosyVoice2-0.5B", "claire", "Female"), + ("FunAudioLLM/CosyVoice2-0.5B", "david", "Male"), + ("FunAudioLLM/CosyVoice2-0.5B", "diana", "Female"), + ] + + # 添加siliconflow:前缀,并格式化为显示名称 + return [ + f"siliconflow:{model}:{voice}-{gender}" + for model, voice, gender in voices_with_gender + ] + + +def get_gemini_voices() -> list[str]: + """ + 获取Gemini TTS的声音列表 + + Returns: + 声音列表,格式为 ["gemini:Zephyr-Female", "gemini:Puck-Male", ...] + """ + # Gemini TTS支持的语音列表 + voices_with_gender = [ + ("Zephyr", "Female"), + ("Puck", "Male"), + ("Charon", "Male"), + ("Kore", "Female"), + ("Fenrir", "Male"), + ("Aoede", "Female"), + ("Thalia", "Female"), + ("Sage", "Male"), + ("Echo", "Female"), + ("Harmony", "Female"), + ("Lux", "Female"), + ("Nova", "Female"), + ("Vale", "Male"), + ("Orion", "Male"), + ("Atlas", "Male"), + ] + + # 添加gemini:前缀,并格式化为显示名称 + return [ + f"gemini:{voice}-{gender}" + for voice, gender in voices_with_gender + ] + + +def get_mimo_voices() -> list[str]: + """ + 获取 Xiaomi MiMo V2.5 TTS 的预置音色列表。 + + 当前只接入官方文档里的 `mimo-v2.5-tts` 预置音色模式。音色设计 + `mimo-v2.5-tts-voicedesign` 和音色复刻 `mimo-v2.5-tts-voiceclone` + 需要额外的输入表单和素材上传流程,先不混入普通 TTS 下拉框,避免 + 用户误以为选择一个 voice id 就能完成所有高级能力。 + """ + voices_with_gender = [ + ("mimo_default", "Female"), + ("冰糖", "Female"), + ("茉莉", "Female"), + ("苏打", "Male"), + ("白桦", "Male"), + ("Mia", "Female"), + ("Chloe", "Female"), + ("Milo", "Male"), + ("Dean", "Male"), + ] + + return [f"mimo:{voice}-{gender}" for voice, gender in voices_with_gender] + + +def get_all_azure_voices(filter_locals=None) -> list[str]: + azure_voices_str = """ +Name: af-ZA-AdriNeural +Gender: Female + +Name: af-ZA-WillemNeural +Gender: Male + +Name: am-ET-AmehaNeural +Gender: Male + +Name: am-ET-MekdesNeural +Gender: Female + +Name: ar-AE-FatimaNeural +Gender: Female + +Name: ar-AE-HamdanNeural +Gender: Male + +Name: ar-BH-AliNeural +Gender: Male + +Name: ar-BH-LailaNeural +Gender: Female + +Name: ar-DZ-AminaNeural +Gender: Female + +Name: ar-DZ-IsmaelNeural +Gender: Male + +Name: ar-EG-SalmaNeural +Gender: Female + +Name: ar-EG-ShakirNeural +Gender: Male + +Name: ar-IQ-BasselNeural +Gender: Male + +Name: ar-IQ-RanaNeural +Gender: Female + +Name: ar-JO-SanaNeural +Gender: Female + +Name: ar-JO-TaimNeural +Gender: Male + +Name: ar-KW-FahedNeural +Gender: Male + +Name: ar-KW-NouraNeural +Gender: Female + +Name: ar-LB-LaylaNeural +Gender: Female + +Name: ar-LB-RamiNeural +Gender: Male + +Name: ar-LY-ImanNeural +Gender: Female + +Name: ar-LY-OmarNeural +Gender: Male + +Name: ar-MA-JamalNeural +Gender: Male + +Name: ar-MA-MounaNeural +Gender: Female + +Name: ar-OM-AbdullahNeural +Gender: Male + +Name: ar-OM-AyshaNeural +Gender: Female + +Name: ar-QA-AmalNeural +Gender: Female + +Name: ar-QA-MoazNeural +Gender: Male + +Name: ar-SA-HamedNeural +Gender: Male + +Name: ar-SA-ZariyahNeural +Gender: Female + +Name: ar-SY-AmanyNeural +Gender: Female + +Name: ar-SY-LaithNeural +Gender: Male + +Name: ar-TN-HediNeural +Gender: Male + +Name: ar-TN-ReemNeural +Gender: Female + +Name: ar-YE-MaryamNeural +Gender: Female + +Name: ar-YE-SalehNeural +Gender: Male + +Name: az-AZ-BabekNeural +Gender: Male + +Name: az-AZ-BanuNeural +Gender: Female + +Name: bg-BG-BorislavNeural +Gender: Male + +Name: bg-BG-KalinaNeural +Gender: Female + +Name: bn-BD-NabanitaNeural +Gender: Female + +Name: bn-BD-PradeepNeural +Gender: Male + +Name: bn-IN-BashkarNeural +Gender: Male + +Name: bn-IN-TanishaaNeural +Gender: Female + +Name: bs-BA-GoranNeural +Gender: Male + +Name: bs-BA-VesnaNeural +Gender: Female + +Name: ca-ES-EnricNeural +Gender: Male + +Name: ca-ES-JoanaNeural +Gender: Female + +Name: cs-CZ-AntoninNeural +Gender: Male + +Name: cs-CZ-VlastaNeural +Gender: Female + +Name: cy-GB-AledNeural +Gender: Male + +Name: cy-GB-NiaNeural +Gender: Female + +Name: da-DK-ChristelNeural +Gender: Female + +Name: da-DK-JeppeNeural +Gender: Male + +Name: de-AT-IngridNeural +Gender: Female + +Name: de-AT-JonasNeural +Gender: Male + +Name: de-CH-JanNeural +Gender: Male + +Name: de-CH-LeniNeural +Gender: Female + +Name: de-DE-AmalaNeural +Gender: Female + +Name: de-DE-ConradNeural +Gender: Male + +Name: de-DE-FlorianMultilingualNeural +Gender: Male + +Name: de-DE-KatjaNeural +Gender: Female + +Name: de-DE-KillianNeural +Gender: Male + +Name: de-DE-SeraphinaMultilingualNeural +Gender: Female + +Name: el-GR-AthinaNeural +Gender: Female + +Name: el-GR-NestorasNeural +Gender: Male + +Name: en-AU-NatashaNeural +Gender: Female + +Name: en-AU-WilliamNeural +Gender: Male + +Name: en-CA-ClaraNeural +Gender: Female + +Name: en-CA-LiamNeural +Gender: Male + +Name: en-GB-LibbyNeural +Gender: Female + +Name: en-GB-MaisieNeural +Gender: Female + +Name: en-GB-RyanNeural +Gender: Male + +Name: en-GB-SoniaNeural +Gender: Female + +Name: en-GB-ThomasNeural +Gender: Male + +Name: en-HK-SamNeural +Gender: Male + +Name: en-HK-YanNeural +Gender: Female + +Name: en-IE-ConnorNeural +Gender: Male + +Name: en-IE-EmilyNeural +Gender: Female + +Name: en-IN-NeerjaExpressiveNeural +Gender: Female + +Name: en-IN-NeerjaNeural +Gender: Female + +Name: en-IN-PrabhatNeural +Gender: Male + +Name: en-KE-AsiliaNeural +Gender: Female + +Name: en-KE-ChilembaNeural +Gender: Male + +Name: en-NG-AbeoNeural +Gender: Male + +Name: en-NG-EzinneNeural +Gender: Female + +Name: en-NZ-MitchellNeural +Gender: Male + +Name: en-NZ-MollyNeural +Gender: Female + +Name: en-PH-JamesNeural +Gender: Male + +Name: en-PH-RosaNeural +Gender: Female + +Name: en-SG-LunaNeural +Gender: Female + +Name: en-SG-WayneNeural +Gender: Male + +Name: en-TZ-ElimuNeural +Gender: Male + +Name: en-TZ-ImaniNeural +Gender: Female + +Name: en-US-AnaNeural +Gender: Female + +Name: en-US-AndrewMultilingualNeural +Gender: Male + +Name: en-US-AndrewNeural +Gender: Male + +Name: en-US-AriaNeural +Gender: Female + +Name: en-US-AvaMultilingualNeural +Gender: Female + +Name: en-US-AvaNeural +Gender: Female + +Name: en-US-BrianMultilingualNeural +Gender: Male + +Name: en-US-BrianNeural +Gender: Male + +Name: en-US-ChristopherNeural +Gender: Male + +Name: en-US-EmmaMultilingualNeural +Gender: Female + +Name: en-US-EmmaNeural +Gender: Female + +Name: en-US-EricNeural +Gender: Male + +Name: en-US-GuyNeural +Gender: Male + +Name: en-US-JennyNeural +Gender: Female + +Name: en-US-MichelleNeural +Gender: Female + +Name: en-US-RogerNeural +Gender: Male + +Name: en-US-SteffanNeural +Gender: Male + +Name: en-ZA-LeahNeural +Gender: Female + +Name: en-ZA-LukeNeural +Gender: Male + +Name: es-AR-ElenaNeural +Gender: Female + +Name: es-AR-TomasNeural +Gender: Male + +Name: es-BO-MarceloNeural +Gender: Male + +Name: es-BO-SofiaNeural +Gender: Female + +Name: es-CL-CatalinaNeural +Gender: Female + +Name: es-CL-LorenzoNeural +Gender: Male + +Name: es-CO-GonzaloNeural +Gender: Male + +Name: es-CO-SalomeNeural +Gender: Female + +Name: es-CR-JuanNeural +Gender: Male + +Name: es-CR-MariaNeural +Gender: Female + +Name: es-CU-BelkysNeural +Gender: Female + +Name: es-CU-ManuelNeural +Gender: Male + +Name: es-DO-EmilioNeural +Gender: Male + +Name: es-DO-RamonaNeural +Gender: Female + +Name: es-EC-AndreaNeural +Gender: Female + +Name: es-EC-LuisNeural +Gender: Male + +Name: es-ES-AlvaroNeural +Gender: Male + +Name: es-ES-ElviraNeural +Gender: Female + +Name: es-ES-XimenaNeural +Gender: Female + +Name: es-GQ-JavierNeural +Gender: Male + +Name: es-GQ-TeresaNeural +Gender: Female + +Name: es-GT-AndresNeural +Gender: Male + +Name: es-GT-MartaNeural +Gender: Female + +Name: es-HN-CarlosNeural +Gender: Male + +Name: es-HN-KarlaNeural +Gender: Female + +Name: es-MX-DaliaNeural +Gender: Female + +Name: es-MX-JorgeNeural +Gender: Male + +Name: es-NI-FedericoNeural +Gender: Male + +Name: es-NI-YolandaNeural +Gender: Female + +Name: es-PA-MargaritaNeural +Gender: Female + +Name: es-PA-RobertoNeural +Gender: Male + +Name: es-PE-AlexNeural +Gender: Male + +Name: es-PE-CamilaNeural +Gender: Female + +Name: es-PR-KarinaNeural +Gender: Female + +Name: es-PR-VictorNeural +Gender: Male + +Name: es-PY-MarioNeural +Gender: Male + +Name: es-PY-TaniaNeural +Gender: Female + +Name: es-SV-LorenaNeural +Gender: Female + +Name: es-SV-RodrigoNeural +Gender: Male + +Name: es-US-AlonsoNeural +Gender: Male + +Name: es-US-PalomaNeural +Gender: Female + +Name: es-UY-MateoNeural +Gender: Male + +Name: es-UY-ValentinaNeural +Gender: Female + +Name: es-VE-PaolaNeural +Gender: Female + +Name: es-VE-SebastianNeural +Gender: Male + +Name: et-EE-AnuNeural +Gender: Female + +Name: et-EE-KertNeural +Gender: Male + +Name: fa-IR-DilaraNeural +Gender: Female + +Name: fa-IR-FaridNeural +Gender: Male + +Name: fi-FI-HarriNeural +Gender: Male + +Name: fi-FI-NooraNeural +Gender: Female + +Name: fil-PH-AngeloNeural +Gender: Male + +Name: fil-PH-BlessicaNeural +Gender: Female + +Name: fr-BE-CharlineNeural +Gender: Female + +Name: fr-BE-GerardNeural +Gender: Male + +Name: fr-CA-AntoineNeural +Gender: Male + +Name: fr-CA-JeanNeural +Gender: Male + +Name: fr-CA-SylvieNeural +Gender: Female + +Name: fr-CA-ThierryNeural +Gender: Male + +Name: fr-CH-ArianeNeural +Gender: Female + +Name: fr-CH-FabriceNeural +Gender: Male + +Name: fr-FR-DeniseNeural +Gender: Female + +Name: fr-FR-EloiseNeural +Gender: Female + +Name: fr-FR-HenriNeural +Gender: Male + +Name: fr-FR-RemyMultilingualNeural +Gender: Male + +Name: fr-FR-VivienneMultilingualNeural +Gender: Female + +Name: ga-IE-ColmNeural +Gender: Male + +Name: ga-IE-OrlaNeural +Gender: Female + +Name: gl-ES-RoiNeural +Gender: Male + +Name: gl-ES-SabelaNeural +Gender: Female + +Name: gu-IN-DhwaniNeural +Gender: Female + +Name: gu-IN-NiranjanNeural +Gender: Male + +Name: he-IL-AvriNeural +Gender: Male + +Name: he-IL-HilaNeural +Gender: Female + +Name: hi-IN-MadhurNeural +Gender: Male + +Name: hi-IN-SwaraNeural +Gender: Female + +Name: hr-HR-GabrijelaNeural +Gender: Female + +Name: hr-HR-SreckoNeural +Gender: Male + +Name: hu-HU-NoemiNeural +Gender: Female + +Name: hu-HU-TamasNeural +Gender: Male + +Name: id-ID-ArdiNeural +Gender: Male + +Name: id-ID-GadisNeural +Gender: Female + +Name: is-IS-GudrunNeural +Gender: Female + +Name: is-IS-GunnarNeural +Gender: Male + +Name: it-IT-DiegoNeural +Gender: Male + +Name: it-IT-ElsaNeural +Gender: Female + +Name: it-IT-GiuseppeMultilingualNeural +Gender: Male + +Name: it-IT-IsabellaNeural +Gender: Female + +Name: iu-Cans-CA-SiqiniqNeural +Gender: Female + +Name: iu-Cans-CA-TaqqiqNeural +Gender: Male + +Name: iu-Latn-CA-SiqiniqNeural +Gender: Female + +Name: iu-Latn-CA-TaqqiqNeural +Gender: Male + +Name: ja-JP-KeitaNeural +Gender: Male + +Name: ja-JP-NanamiNeural +Gender: Female + +Name: jv-ID-DimasNeural +Gender: Male + +Name: jv-ID-SitiNeural +Gender: Female + +Name: ka-GE-EkaNeural +Gender: Female + +Name: ka-GE-GiorgiNeural +Gender: Male + +Name: kk-KZ-AigulNeural +Gender: Female + +Name: kk-KZ-DauletNeural +Gender: Male + +Name: km-KH-PisethNeural +Gender: Male + +Name: km-KH-SreymomNeural +Gender: Female + +Name: kn-IN-GaganNeural +Gender: Male + +Name: kn-IN-SapnaNeural +Gender: Female + +Name: ko-KR-HyunsuMultilingualNeural +Gender: Male + +Name: ko-KR-InJoonNeural +Gender: Male + +Name: ko-KR-SunHiNeural +Gender: Female + +Name: lo-LA-ChanthavongNeural +Gender: Male + +Name: lo-LA-KeomanyNeural +Gender: Female + +Name: lt-LT-LeonasNeural +Gender: Male + +Name: lt-LT-OnaNeural +Gender: Female + +Name: lv-LV-EveritaNeural +Gender: Female + +Name: lv-LV-NilsNeural +Gender: Male + +Name: mk-MK-AleksandarNeural +Gender: Male + +Name: mk-MK-MarijaNeural +Gender: Female + +Name: ml-IN-MidhunNeural +Gender: Male + +Name: ml-IN-SobhanaNeural +Gender: Female + +Name: mn-MN-BataaNeural +Gender: Male + +Name: mn-MN-YesuiNeural +Gender: Female + +Name: mr-IN-AarohiNeural +Gender: Female + +Name: mr-IN-ManoharNeural +Gender: Male + +Name: ms-MY-OsmanNeural +Gender: Male + +Name: ms-MY-YasminNeural +Gender: Female + +Name: mt-MT-GraceNeural +Gender: Female + +Name: mt-MT-JosephNeural +Gender: Male + +Name: my-MM-NilarNeural +Gender: Female + +Name: my-MM-ThihaNeural +Gender: Male + +Name: nb-NO-FinnNeural +Gender: Male + +Name: nb-NO-PernilleNeural +Gender: Female + +Name: ne-NP-HemkalaNeural +Gender: Female + +Name: ne-NP-SagarNeural +Gender: Male + +Name: nl-BE-ArnaudNeural +Gender: Male + +Name: nl-BE-DenaNeural +Gender: Female + +Name: nl-NL-ColetteNeural +Gender: Female + +Name: nl-NL-FennaNeural +Gender: Female + +Name: nl-NL-MaartenNeural +Gender: Male + +Name: pl-PL-MarekNeural +Gender: Male + +Name: pl-PL-ZofiaNeural +Gender: Female + +Name: ps-AF-GulNawazNeural +Gender: Male + +Name: ps-AF-LatifaNeural +Gender: Female + +Name: pt-BR-AntonioNeural +Gender: Male + +Name: pt-BR-FranciscaNeural +Gender: Female + +Name: pt-BR-ThalitaMultilingualNeural +Gender: Female + +Name: pt-PT-DuarteNeural +Gender: Male + +Name: pt-PT-RaquelNeural +Gender: Female + +Name: ro-RO-AlinaNeural +Gender: Female + +Name: ro-RO-EmilNeural +Gender: Male + +Name: ru-RU-DmitryNeural +Gender: Male + +Name: ru-RU-SvetlanaNeural +Gender: Female + +Name: si-LK-SameeraNeural +Gender: Male + +Name: si-LK-ThiliniNeural +Gender: Female + +Name: sk-SK-LukasNeural +Gender: Male + +Name: sk-SK-ViktoriaNeural +Gender: Female + +Name: sl-SI-PetraNeural +Gender: Female + +Name: sl-SI-RokNeural +Gender: Male + +Name: so-SO-MuuseNeural +Gender: Male + +Name: so-SO-UbaxNeural +Gender: Female + +Name: sq-AL-AnilaNeural +Gender: Female + +Name: sq-AL-IlirNeural +Gender: Male + +Name: sr-RS-NicholasNeural +Gender: Male + +Name: sr-RS-SophieNeural +Gender: Female + +Name: su-ID-JajangNeural +Gender: Male + +Name: su-ID-TutiNeural +Gender: Female + +Name: sv-SE-MattiasNeural +Gender: Male + +Name: sv-SE-SofieNeural +Gender: Female + +Name: sw-KE-RafikiNeural +Gender: Male + +Name: sw-KE-ZuriNeural +Gender: Female + +Name: sw-TZ-DaudiNeural +Gender: Male + +Name: sw-TZ-RehemaNeural +Gender: Female + +Name: ta-IN-PallaviNeural +Gender: Female + +Name: ta-IN-ValluvarNeural +Gender: Male + +Name: ta-LK-KumarNeural +Gender: Male + +Name: ta-LK-SaranyaNeural +Gender: Female + +Name: ta-MY-KaniNeural +Gender: Female + +Name: ta-MY-SuryaNeural +Gender: Male + +Name: ta-SG-AnbuNeural +Gender: Male + +Name: ta-SG-VenbaNeural +Gender: Female + +Name: te-IN-MohanNeural +Gender: Male + +Name: te-IN-ShrutiNeural +Gender: Female + +Name: th-TH-NiwatNeural +Gender: Male + +Name: th-TH-PremwadeeNeural +Gender: Female + +Name: tr-TR-AhmetNeural +Gender: Male + +Name: tr-TR-EmelNeural +Gender: Female + +Name: uk-UA-OstapNeural +Gender: Male + +Name: uk-UA-PolinaNeural +Gender: Female + +Name: ur-IN-GulNeural +Gender: Female + +Name: ur-IN-SalmanNeural +Gender: Male + +Name: ur-PK-AsadNeural +Gender: Male + +Name: ur-PK-UzmaNeural +Gender: Female + +Name: uz-UZ-MadinaNeural +Gender: Female + +Name: uz-UZ-SardorNeural +Gender: Male + +Name: vi-VN-HoaiMyNeural +Gender: Female + +Name: vi-VN-NamMinhNeural +Gender: Male + +Name: zh-CN-XiaoxiaoNeural +Gender: Female + +Name: zh-CN-XiaoyiNeural +Gender: Female + +Name: zh-CN-YunjianNeural +Gender: Male + +Name: zh-CN-YunxiNeural +Gender: Male + +Name: zh-CN-YunxiaNeural +Gender: Male + +Name: zh-CN-YunyangNeural +Gender: Male + +Name: zh-CN-liaoning-XiaobeiNeural +Gender: Female + +Name: zh-CN-shaanxi-XiaoniNeural +Gender: Female + +Name: zh-HK-HiuGaaiNeural +Gender: Female + +Name: zh-HK-HiuMaanNeural +Gender: Female + +Name: zh-HK-WanLungNeural +Gender: Male + +Name: zh-TW-HsiaoChenNeural +Gender: Female + +Name: zh-TW-HsiaoYuNeural +Gender: Female + +Name: zh-TW-YunJheNeural +Gender: Male + +Name: zu-ZA-ThandoNeural +Gender: Female + +Name: zu-ZA-ThembaNeural +Gender: Male + + +Name: en-US-AvaMultilingualNeural-V2 +Gender: Female + +Name: en-US-AndrewMultilingualNeural-V2 +Gender: Male + +Name: en-US-EmmaMultilingualNeural-V2 +Gender: Female + +Name: en-US-BrianMultilingualNeural-V2 +Gender: Male + +Name: de-DE-FlorianMultilingualNeural-V2 +Gender: Male + +Name: de-DE-SeraphinaMultilingualNeural-V2 +Gender: Female + +Name: fr-FR-RemyMultilingualNeural-V2 +Gender: Male + +Name: fr-FR-VivienneMultilingualNeural-V2 +Gender: Female + +Name: zh-CN-XiaoxiaoMultilingualNeural-V2 +Gender: Female + """.strip() + voices = [] + # 定义正则表达式模式,用于匹配 Name 和 Gender 行 + pattern = re.compile(r"Name:\s*(.+)\s*Gender:\s*(.+)\s*", re.MULTILINE) + # 使用正则表达式查找所有匹配项 + matches = pattern.findall(azure_voices_str) + + for name, gender in matches: + # 应用过滤条件 + if filter_locals and any( + name.lower().startswith(fl.lower()) for fl in filter_locals + ): + voices.append(f"{name}-{gender}") + elif not filter_locals: + voices.append(f"{name}-{gender}") + + voices.sort() + return voices + + +def parse_voice_name(name: str): + # zh-CN-XiaoyiNeural-Female + # zh-CN-YunxiNeural-Male + # zh-CN-XiaoxiaoMultilingualNeural-V2-Female + name = name.replace("-Female", "").replace("-Male", "").strip() + return name + + +def is_azure_v2_voice(voice_name: str): + voice_name = parse_voice_name(voice_name) + if voice_name.endswith("-V2"): + return voice_name.replace("-V2", "").strip() + return "" + + +def is_siliconflow_voice(voice_name: str): + """检查是否是硅基流动的声音""" + return voice_name.startswith("siliconflow:") + + +def is_gemini_voice(voice_name: str): + """检查是否是Gemini TTS的声音""" + return voice_name.startswith("gemini:") + + +def is_mimo_voice(voice_name: str): + """检查是否是 Xiaomi MiMo TTS 的声音""" + return voice_name.startswith("mimo:") + + +def tts( + text: str, + voice_name: str, + voice_rate: float, + voice_file: str, + voice_volume: float = 1.0, +) -> Union[SubMaker, None]: + if is_azure_v2_voice(voice_name): + return azure_tts_v2(text, voice_name, voice_file) + elif is_siliconflow_voice(voice_name): + # 从voice_name中提取模型和声音 + # 格式: siliconflow:model:voice-Gender + parts = voice_name.split(":") + if len(parts) >= 3: + model = parts[1] + # 移除性别后缀,例如 "alex-Male" -> "alex" + voice_with_gender = parts[2] + voice = voice_with_gender.split("-")[0] + # 构建完整的voice参数,格式为 "model:voice" + full_voice = f"{model}:{voice}" + return siliconflow_tts( + text, model, full_voice, voice_rate, voice_file, voice_volume + ) + else: + logger.error(f"Invalid siliconflow voice name format: {voice_name}") + return None + elif is_gemini_voice(voice_name): + # 从voice_name中提取声音名称 + # 格式: gemini:voice-Gender + parts = voice_name.split(":") + if len(parts) >= 2: + # 移除性别后缀,例如 "Zephyr-Female" -> "Zephyr" + voice_with_gender = parts[1] + voice = voice_with_gender.split("-")[0] + return gemini_tts(text, voice, voice_rate, voice_file, voice_volume) + else: + logger.error(f"Invalid gemini voice name format: {voice_name}") + return None + elif is_mimo_voice(voice_name): + # 从voice_name中提取声音名称 + # 格式: mimo:voice-Gender;如果调用方已执行 parse_voice_name, + # 则可能是 mimo:voice。两种格式都兼容。 + parts = voice_name.split(":") + if len(parts) >= 2: + voice_with_gender = parts[1] + voice = voice_with_gender.split("-")[0] + return mimo_tts(text, voice, voice_rate, voice_file, voice_volume) + else: + logger.error(f"Invalid mimo voice name format: {voice_name}") + return None + return azure_tts_v1(text, voice_name, voice_rate, voice_file) + + +def convert_rate_to_percent(rate: float) -> str: + # edge-tts requires a sign-prefixed percentage (e.g. "+0%", "-20%"). + # Rounding can yield 0 for rates near but not equal to 1.0 (e.g. 1.004, + # 0.997); those must still be returned as "+0%", not the unsigned "0%" + # which edge-tts rejects with ValueError: Invalid rate '0%'. + percent = round((rate - 1.0) * 100) + if percent >= 0: + return f"+{percent}%" + return f"{percent}%" + + +def ensure_file_path_exists(file_path: str) -> None: + """ + 确保输出文件所在目录一定存在。 + + 这里单独做一层兜底,是因为 edge_tts 7.x 在真正发起网络请求之前, + 就会先打开目标音频文件;如果目录不存在,会直接因为本地文件路径报错, + 从而掩盖真正的 TTS 行为结果。 + """ + dir_path = os.path.dirname(file_path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + + +def ensure_legacy_submaker_fields(sub_maker: SubMaker) -> SubMaker: + """ + 为项目里仍然沿用旧字幕结构的调用方补齐兼容字段。 + + edge_tts 7.x 的 `SubMaker` 主要暴露 `cues/get_srt()`,但项目里 Azure v2、 + Gemini、SiliconFlow 这些路径仍然会直接读写 `subs/offset`。这里统一补齐, + 避免升级 edge_tts 后这些非 edge 路径被连带破坏。 + """ + if not hasattr(sub_maker, "subs"): + sub_maker.subs = [] + if not hasattr(sub_maker, "offset"): + sub_maker.offset = [] + return sub_maker + + +def populate_legacy_submaker_with_full_text( + sub_maker: SubMaker, text: str, audio_duration_seconds: float +) -> SubMaker: + """ + 用整段文本填充项目历史沿用的 `subs/offset` 字幕结构。 + + 背景: + 1. edge_tts 7.x 的 `SubMaker` 不再提供旧版本里的 `create_sub()`; + 2. 项目里 Gemini、SiliconFlow 等非 edge 路径依然需要返回一个 + 带 `subs/offset` 的对象,供后续统一计算音频时长和生成字幕; + 3. 对于拿不到逐词边界的 TTS 服务,需要至少按脚本断句切成多个片段, + 这样后续 `subtitle_provider=edge` 的聚合逻辑才能继续工作,而不是 + 因为整段文本无法和脚本断句逐行匹配而回退 Whisper。 + + Args: + sub_maker: 需要写入兼容字段的字幕对象 + text: 原始脚本文本 + audio_duration_seconds: 音频总时长,单位秒 + + Returns: + 已填充兼容字幕数据的 SubMaker 对象 + """ + sub_maker = ensure_legacy_submaker_fields(sub_maker) + + # 清空旧值,避免调用方重复复用对象时出现脏数据叠加。 + sub_maker.subs = [] + sub_maker.offset = [] + + normalized_text = (text or "").strip() + if not normalized_text: + return sub_maker + + audio_duration_100ns = max(int(audio_duration_seconds * 10000000), 1) + + # Gemini / SiliconFlow 这类路径拿不到逐词边界时,仍然尽量沿用项目 + # 原来的“按标点断句 + 按字符数比例分配时长”的策略。这样既能让 + # create_subtitle() 匹配脚本断句,也能避免再次回退 Whisper。 + sentences = utils.split_string_by_punctuations(normalized_text) + if not sentences: + sentences = [normalized_text] + + total_chars = sum(len(sentence) for sentence in sentences) + if total_chars <= 0: + sub_maker.subs.append(normalized_text) + sub_maker.offset.append((0, audio_duration_100ns)) + return sub_maker + + current_offset = 0 + for index, sentence in enumerate(sentences): + cleaned_sentence = sentence.strip() + if not cleaned_sentence: + continue + + # 前面的句子按字符数比例分配时长,最后一句兜底吃掉剩余时长, + # 避免整数取整导致总时长丢失或字幕结束时间短于音频。 + if index == len(sentences) - 1: + sentence_end = audio_duration_100ns + else: + sentence_chars = len(cleaned_sentence) + sentence_duration = max( + int(audio_duration_100ns * (sentence_chars / total_chars)), + 1, + ) + sentence_end = min(current_offset + sentence_duration, audio_duration_100ns) + + sub_maker.subs.append(cleaned_sentence) + sub_maker.offset.append((current_offset, sentence_end)) + current_offset = sentence_end + + return sub_maker + + +def create_edge_tts_communicate( + text: str, voice_name: str, rate_str: str +) -> edge_tts.Communicate: + """ + 按当前已安装的 edge_tts 版本构造 Communicate 对象。 + + 背景: + 1. 主线代码已经升级到 edge_tts 7.x,并使用 `boundary` 参数拿到更细的边界事件; + 2. 但 Windows 便携包如果更新失败,现场环境可能仍然停留在旧版 edge_tts; + 3. 旧版 `Communicate.__init__()` 不接受 `boundary`,会直接抛出 + `unexpected keyword argument 'boundary'`,导致整个 TTS 链路失败。 + + 因此这里先根据构造函数签名探测当前版本支持的参数,再决定是否传入 + `boundary`,让同一份代码同时兼容旧版和新版依赖。 + """ + communicate_kwargs = {"rate": rate_str} + communicate_signature = inspect.signature(edge_tts.Communicate) + + if "boundary" in communicate_signature.parameters: + communicate_kwargs["boundary"] = "WordBoundary" + + return edge_tts.Communicate(text, voice_name, **communicate_kwargs) + + +def get_edge_tts_timeout_seconds() -> Union[float, None]: + """ + 获取 Azure TTS V1 单次流式请求的超时时间。 + + 背景: + Edge consumer TTS 在网络不通、服务端限流、voice 与文本语言不匹配等场景下, + 可能长时间卡在 `stream_sync()` 内部,日志只停留在 `start`。这里提供一个 + 默认超时,避免 WebUI 任务长期无反馈。 + + 使用方式: + - 默认 30 秒,覆盖常见短视频脚本的首包等待时间; + - 如用户处于慢网络或代理环境,可在 `config.toml` 里设置 + `edge_tts_timeout = 60`; + - 设置为 0 或负数表示显式禁用超时,保留完全向后兼容。 + """ + raw_timeout = config.app.get( + "edge_tts_timeout", _DEFAULT_EDGE_TTS_TIMEOUT_SECONDS + ) + try: + timeout_seconds = float(raw_timeout) + except (TypeError, ValueError): + logger.warning( + "invalid edge_tts_timeout: " + f"{raw_timeout}, fallback to {_DEFAULT_EDGE_TTS_TIMEOUT_SECONDS}s" + ) + timeout_seconds = _DEFAULT_EDGE_TTS_TIMEOUT_SECONDS + + if timeout_seconds <= 0: + return None + + return timeout_seconds + + +def _stream_edge_tts_sync_with_timeout( + communicate, on_chunk, timeout_seconds: float +) -> None: + """ + 带总超时地消费 edge_tts 7.x 的同步流。 + + 实现原因: + `stream_sync()` 本身是阻塞迭代器,网络层卡住时主线程无法及时恢复。 + 这里把阻塞迭代放到 daemon 线程中,主线程通过 Queue 获取 chunk, + 到达超时时间后直接抛出 TimeoutError,让外层重试和错误日志继续工作。 + + 注意: + daemon 线程只作为兜底保护使用,最多随 Azure TTS V1 的 3 次重试产生 + 少量残留线程;进程退出时会自动回收。相比 WebUI 任务永久卡住,这是 + 更可控的失败模式。 + """ + stream_queue = queue.Queue() + done_marker = object() + + def _produce_chunks(): + try: + for chunk in communicate.stream_sync(): + stream_queue.put(("chunk", chunk)) + stream_queue.put(("done", done_marker)) + except Exception as e: + stream_queue.put(("error", e)) + + thread = threading.Thread(target=_produce_chunks, daemon=True) + thread.start() + + deadline = time.monotonic() + timeout_seconds + while True: + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + raise TimeoutError( + f"edge_tts stream timed out after {timeout_seconds:g}s" + ) + + try: + item_type, payload = stream_queue.get( + timeout=min(0.5, remaining_seconds) + ) + except queue.Empty: + continue + + if item_type == "chunk": + on_chunk(payload) + elif item_type == "error": + raise payload + elif item_type == "done": + return + + +def stream_edge_tts_chunks( + communicate, on_chunk, timeout_seconds: Union[float, None] = None +) -> None: + """ + 统一消费 edge_tts 的同步流和旧版异步流。 + + edge_tts 7.x 提供 `stream_sync()`,可以在同步函数里直接迭代; + 更早的版本通常只有异步 `stream()`。为了让 `azure_tts_v1()` 在 + 旧依赖残留场景下仍能继续工作,这里统一做一层流式兼容。 + + Args: + communicate: edge_tts.Communicate 实例 + on_chunk: 每拿到一个事件块时执行的回调 + timeout_seconds: 单次流式请求总超时;为 None 时不启用超时。 + """ + if hasattr(communicate, "stream_sync"): + if timeout_seconds: + _stream_edge_tts_sync_with_timeout( + communicate, on_chunk, timeout_seconds + ) + return + + for chunk in communicate.stream_sync(): + on_chunk(chunk) + return + + if not hasattr(communicate, "stream"): + raise AttributeError("edge_tts communicate object has no stream method") + + async def _consume_async_stream(): + async for chunk in communicate.stream(): + on_chunk(chunk) + + # 这里显式创建独立事件循环,而不是复用外部上下文,目的是避免 + # 在同步调用栈里遇到“当前线程没有事件循环”或跨线程复用循环的问题。 + loop = asyncio.new_event_loop() + try: + if timeout_seconds: + loop.run_until_complete( + asyncio.wait_for(_consume_async_stream(), timeout=timeout_seconds) + ) + else: + loop.run_until_complete(_consume_async_stream()) + finally: + loop.close() + + +def azure_tts_v1( + text: str, voice_name: str, voice_rate: float, voice_file: str +) -> Union[SubMaker, None]: + voice_name = parse_voice_name(voice_name) + text = text.strip() + rate_str = convert_rate_to_percent(voice_rate) + for i in range(3): + try: + logger.info(f"start, voice name: {voice_name}, try: {i + 1}") + + # 这里同时兼容 edge_tts 7.x 和旧版便携包里可能残留的老依赖: + # 1. 新版支持 `boundary` + `stream_sync()` + # 2. 旧版不支持 `boundary`,且通常只暴露异步 `stream()` + ensure_file_path_exists(voice_file) + communicate = create_edge_tts_communicate(text, voice_name, rate_str) + sub_maker = edge_tts.SubMaker() + timeout_seconds = get_edge_tts_timeout_seconds() + + with open(voice_file, "wb") as file: + def _handle_chunk(chunk): + chunk_type = chunk["type"] + if chunk_type == "audio": + file.write(chunk["data"]) + elif chunk_type in ["WordBoundary", "SentenceBoundary"]: + # 无论来自 7.x 的同步流,还是旧版异步流,只要事件结构 + # 里仍有边界信息,就统一喂给 SubMaker,保证后续字幕链路 + # 仍然走项目现有逻辑。 + sub_maker.feed(chunk) + + stream_edge_tts_chunks( + communicate, _handle_chunk, timeout_seconds=timeout_seconds + ) + + if not sub_maker.get_srt(): + logger.warning("failed, sub_maker.get_srt() is empty") + continue + + logger.info(f"completed, output file: {voice_file}") + return sub_maker + except Exception as e: + logger.error(f"failed, error: {str(e)}") + # TTS 流式写入如果在首包前超时或网络异常,会留下 0 字节音频文件。 + # 这种文件既不可播放,也可能误导后续排查,因此失败后只清理空文件; + # 如果已经写入了部分数据,则保留现场文件,便于分析服务端返回内容。 + if os.path.exists(voice_file) and os.path.getsize(voice_file) == 0: + try: + os.remove(voice_file) + except Exception as remove_error: + logger.warning( + "failed to remove empty tts file: " + f"{voice_file}, error: {str(remove_error)}" + ) + return None + + +def siliconflow_tts( + text: str, + model: str, + voice: str, + voice_rate: float, + voice_file: str, + voice_volume: float = 1.0, +) -> Union[SubMaker, None]: + """ + 使用硅基流动的API生成语音 + + Args: + text: 要转换为语音的文本 + model: 模型名称,如 "FunAudioLLM/CosyVoice2-0.5B" + voice: 声音名称,如 "FunAudioLLM/CosyVoice2-0.5B:alex" + voice_rate: 语音速度,范围[0.25, 4.0] + voice_file: 输出的音频文件路径 + voice_volume: 语音音量,范围[0.6, 5.0],需要转换为硅基流动的增益范围[-10, 10] + + Returns: + SubMaker对象或None + """ + text = text.strip() + api_key = config.siliconflow.get("api_key", "") + + if not api_key: + logger.error("SiliconFlow API key is not set") + return None + + # 将voice_volume转换为硅基流动的增益范围 + # 默认voice_volume为1.0,对应gain为0 + gain = voice_volume - 1.0 + # 确保gain在[-10, 10]范围内 + gain = max(-10, min(10, gain)) + + url = "https://api.siliconflow.cn/v1/audio/speech" + + payload = { + "model": model, + "input": text, + "voice": voice, + "response_format": "mp3", + "sample_rate": 32000, + "stream": False, + "speed": voice_rate, + "gain": gain, + } + + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + + for i in range(3): # 尝试3次 + try: + logger.info( + f"start siliconflow tts, model: {model}, voice: {voice}, try: {i + 1}" + ) + + response = requests.post(url, json=payload, headers=headers) + + if response.status_code == 200: + # 保存音频文件 + with open(voice_file, "wb") as f: + f.write(response.content) + + # 这里仍然沿用项目原有的字幕结构,因此需要补齐旧字段。 + sub_maker = ensure_legacy_submaker_fields(SubMaker()) + + # 获取音频文件的实际长度 + try: + # 尝试使用moviepy获取音频长度 + from moviepy import AudioFileClip + + audio_clip = AudioFileClip(voice_file) + audio_duration = audio_clip.duration + audio_clip.close() + + # 将音频长度转换为100纳秒单位(与edge_tts兼容) + audio_duration_100ns = int(audio_duration * 10000000) + + # 使用文本分割来创建更准确的字幕 + # 将文本按标点符号分割成句子 + sentences = utils.split_string_by_punctuations(text) + + if sentences: + # 计算每个句子的大致时长(按字符数比例分配) + total_chars = sum(len(s) for s in sentences) + char_duration = ( + audio_duration_100ns / total_chars if total_chars > 0 else 0 + ) + + current_offset = 0 + for sentence in sentences: + if not sentence.strip(): + continue + + # 计算当前句子的时长 + sentence_chars = len(sentence) + sentence_duration = int(sentence_chars * char_duration) + + # 添加到SubMaker + sub_maker.subs.append(sentence) + sub_maker.offset.append( + (current_offset, current_offset + sentence_duration) + ) + + # 更新偏移量 + current_offset += sentence_duration + else: + # 如果无法分割,则使用整个文本作为一个字幕 + sub_maker.subs = [text] + sub_maker.offset = [(0, audio_duration_100ns)] + + except Exception as e: + logger.warning(f"Failed to create accurate subtitles: {str(e)}") + # 回退到简单的字幕 + sub_maker.subs = [text] + # 使用音频文件的实际长度,如果无法获取,则假设为10秒 + sub_maker.offset = [ + ( + 0, + audio_duration_100ns + if "audio_duration_100ns" in locals() + else 10000000, + ) + ] + + logger.success(f"siliconflow tts succeeded: {voice_file}") + logger.debug( + "siliconflow subtitle timeline generated, " + f"subs: {len(sub_maker.subs)}, offsets: {len(sub_maker.offset)}" + ) + return sub_maker + else: + logger.error( + f"siliconflow tts failed with status code {response.status_code}: {response.text}" + ) + except Exception as e: + logger.error(f"siliconflow tts failed: {str(e)}") + + return None + + +def azure_tts_v2(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]: + voice_name = is_azure_v2_voice(voice_name) + if not voice_name: + logger.error(f"invalid voice name: {voice_name}") + raise ValueError(f"invalid voice name: {voice_name}") + text = text.strip() + + def _format_duration_to_offset(duration) -> int: + if isinstance(duration, str): + time_obj = datetime.strptime(duration, "%H:%M:%S.%f") + milliseconds = ( + (time_obj.hour * 3600000) + + (time_obj.minute * 60000) + + (time_obj.second * 1000) + + (time_obj.microsecond // 1000) + ) + return milliseconds * 10000 + + if isinstance(duration, int): + return duration + + return 0 + + for i in range(3): + try: + logger.info(f"start, voice name: {voice_name}, try: {i + 1}") + + import azure.cognitiveservices.speech as speechsdk + + sub_maker = ensure_legacy_submaker_fields(SubMaker()) + + def speech_synthesizer_word_boundary_cb(evt: speechsdk.SessionEventArgs): + # print('WordBoundary event:') + # print('\tBoundaryType: {}'.format(evt.boundary_type)) + # print('\tAudioOffset: {}ms'.format((evt.audio_offset + 5000))) + # print('\tDuration: {}'.format(evt.duration)) + # print('\tText: {}'.format(evt.text)) + # print('\tTextOffset: {}'.format(evt.text_offset)) + # print('\tWordLength: {}'.format(evt.word_length)) + + duration = _format_duration_to_offset(str(evt.duration)) + offset = _format_duration_to_offset(evt.audio_offset) + sub_maker.subs.append(evt.text) + sub_maker.offset.append((offset, offset + duration)) + + # Creates an instance of a speech config with specified subscription key and service region. + speech_key = config.azure.get("speech_key", "") + service_region = config.azure.get("speech_region", "") + if not speech_key or not service_region: + logger.error("Azure speech key or region is not set") + return None + + audio_config = speechsdk.audio.AudioOutputConfig( + filename=voice_file, use_default_speaker=True + ) + speech_config = speechsdk.SpeechConfig( + subscription=speech_key, region=service_region + ) + speech_config.speech_synthesis_voice_name = voice_name + # speech_config.set_property(property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestSentenceBoundary, + # value='true') + speech_config.set_property( + property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestWordBoundary, + value="true", + ) + + speech_config.set_speech_synthesis_output_format( + speechsdk.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3 + ) + speech_synthesizer = speechsdk.SpeechSynthesizer( + audio_config=audio_config, speech_config=speech_config + ) + speech_synthesizer.synthesis_word_boundary.connect( + speech_synthesizer_word_boundary_cb + ) + + result = speech_synthesizer.speak_text_async(text).get() + if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted: + logger.success(f"azure v2 speech synthesis succeeded: {voice_file}") + return sub_maker + elif result.reason == speechsdk.ResultReason.Canceled: + cancellation_details = result.cancellation_details + logger.error( + f"azure v2 speech synthesis canceled: {cancellation_details.reason}" + ) + if cancellation_details.reason == speechsdk.CancellationReason.Error: + logger.error( + f"azure v2 speech synthesis error: {cancellation_details.error_details}" + ) + logger.info(f"completed, output file: {voice_file}") + except Exception as e: + logger.error(f"failed, error: {str(e)}") + return None + + +def gemini_tts( + text: str, + voice_name: str, + voice_rate: float, + voice_file: str, + voice_volume: float = 1.0, +) -> Union[SubMaker, None]: + """ + 使用Google Gemini TTS生成语音 + + Args: + text: 要转换的文本 + voice_name: 语音名称,如 "Zephyr", "Puck" 等 + voice_rate: 语音速率(当前未使用) + voice_file: 输出音频文件路径 + voice_volume: 音频音量(当前未使用) + + Returns: + SubMaker对象或None + """ + import base64 + import json + import io + from pydub import AudioSegment + import google.generativeai as genai + _configure_pydub_ffmpeg(AudioSegment) + + try: + # 配置Gemini API + api_key = config.app.get("gemini_api_key", "") + if not api_key: + logger.error("Gemini API key is not set") + return None + + genai.configure(api_key=api_key) + + logger.info(f"start, voice name: {voice_name}, try: 1") + + # 使用Gemini TTS API + model = genai.GenerativeModel("gemini-2.5-flash-preview-tts") + + generation_config = { + "response_modalities": ["AUDIO"], + "speech_config": { + "voice_config": { + "prebuilt_voice_config": { + "voice_name": voice_name + } + } + } + } + + response = model.generate_content( + contents=text, + generation_config=generation_config + ) + + # 检查响应 + if not response.candidates or not response.candidates[0].content: + logger.error("No audio content received from Gemini TTS") + return None + + # 获取音频数据 + audio_data = None + for part in response.candidates[0].content.parts: + if hasattr(part, 'inline_data') and part.inline_data: + audio_data = part.inline_data.data + break + + if not audio_data: + logger.error("No audio data found in response") + return None + + # 音频数据已经是原始字节,不需要base64解码 + if isinstance(audio_data, str): + # 如果是字符串,则需要base64解码 + audio_bytes = base64.b64decode(audio_data) + else: + # 如果已经是字节,直接使用 + audio_bytes = audio_data + + # 尝试不同的音频格式 - Gemini可能返回不同的格式 + audio_segment = None + + # Gemini返回Linear PCM格式,按照文档参数解析 + try: + audio_segment = AudioSegment.from_file( + io.BytesIO(audio_bytes), + format="raw", + frame_rate=24000, # Gemini TTS默认采样率 + channels=1, # 单声道 + sample_width=2 # 16-bit + ) + except Exception as e: + logger.error(f"Failed to load PCM audio: {e}") + return None + + # 导出为MP3格式 + audio_segment.export(voice_file, format="mp3") + + logger.info(f"completed, output file: {voice_file}") + + # Gemini 拿不到 edge_tts 那种逐词边界事件,因此这里退回到 + # 项目原有的 `subs/offset` 兼容结构,至少保证后续字幕与时长 + # 计算链路可继续工作。 + sub_maker = ensure_legacy_submaker_fields(SubMaker()) + audio_duration = len(audio_segment) / 1000.0 # 转换为秒 + return populate_legacy_submaker_with_full_text( + sub_maker=sub_maker, + text=text, + audio_duration_seconds=audio_duration, + ) + + except ImportError as e: + logger.error(f"Missing required package for Gemini TTS: {str(e)}. Please install: pip install pydub") + return None + except Exception as e: + logger.error(f"Gemini TTS failed, error: {str(e)}") + return None + + +def mimo_tts( + text: str, + voice_name: str, + voice_rate: float, + voice_file: str, + voice_volume: float = 1.0, +) -> Union[SubMaker, None]: + """ + 使用 Xiaomi MiMo V2.5 TTS 生成语音。 + + 官方接口兼容 OpenAI Chat Completions,但 TTS 有两个关键差异: + 1. 待合成文本必须放在 `assistant` 消息里; + 2. 音频以 `message.audio.data` 的 base64 字符串返回。 + + MiMo 当前没有返回逐词时间轴,因此这里复用项目已有的 legacy + SubMaker 兜底方案:根据最终音频时长和脚本文本断句生成字幕时间轴。 + """ + from pydub import AudioSegment + + text = (text or "").strip() + if not text: + logger.error("MiMo TTS text is empty") + return None + + api_key = config.app.get("mimo_api_key", "") + if not api_key: + logger.error("MiMo API key is not set") + return None + + base_url = config.app.get("mimo_base_url", "") or _MIMO_DEFAULT_BASE_URL + model_name = config.app.get("mimo_tts_model_name", "") or _MIMO_DEFAULT_TTS_MODEL + style_prompt = config.app.get( + "mimo_tts_style_prompt", + "请用自然、清晰、适合短视频旁白的语气朗读。", + ) + + _configure_pydub_ffmpeg(AudioSegment) + + for i in range(3): + try: + logger.info( + f"start mimo tts, model: {model_name}, voice: {voice_name}, try: {i + 1}" + ) + ensure_file_path_exists(voice_file) + + client = OpenAI(api_key=api_key, base_url=base_url) + completion = client.chat.completions.create( + model=model_name, + messages=[ + {"role": "user", "content": style_prompt}, + {"role": "assistant", "content": text}, + ], + audio={ + "format": "wav", + "voice": voice_name, + }, + ) + + if not completion or not getattr(completion, "choices", None): + raise ValueError("MiMo TTS returned empty response") + + message = completion.choices[0].message + audio = getattr(message, "audio", None) + audio_data = None + if isinstance(audio, dict): + audio_data = audio.get("data") + elif audio is not None: + audio_data = getattr(audio, "data", None) + + if not audio_data: + raise ValueError("MiMo TTS returned empty audio data") + + audio_bytes = base64.b64decode(audio_data) + audio_segment = AudioSegment.from_file(io.BytesIO(audio_bytes), format="wav") + + output_format = utils.parse_extension(voice_file) or "mp3" + if output_format == "wav": + with open(voice_file, "wb") as f: + f.write(audio_bytes) + else: + audio_segment.export(voice_file, format=output_format) + + audio_duration = len(audio_segment) / 1000.0 + sub_maker = ensure_legacy_submaker_fields(SubMaker()) + logger.success(f"mimo tts succeeded: {voice_file}") + logger.debug( + "mimo subtitle timeline generated, " + f"duration: {audio_duration:.3f}s, output_format: {output_format}" + ) + return populate_legacy_submaker_with_full_text( + sub_maker=sub_maker, + text=text, + audio_duration_seconds=audio_duration, + ) + except Exception as e: + logger.error(f"mimo tts failed: {str(e)}") + + return None + + +def _format_text(text: str) -> str: + # text = text.replace("\n", " ") + text = text.replace("[", " ") + text = text.replace("]", " ") + text = text.replace("(", " ") + text = text.replace(")", " ") + text = text.replace("{", " ") + text = text.replace("}", " ") + text = text.strip() + return text + + +def _build_subtitle_formatter(): + """ + 返回统一的 SRT 行格式化函数。 + + 这里单独拆成一个小工具,是为了让 edge_tts 7.x 的 cues 路径 + 和项目原有的 legacy `subs/offset` 路径共用同一套字幕落盘格式, + 避免两套逻辑各自产生细微格式差异。 + """ + + def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str: + start_t = mktimestamp(start_time).replace(".", ",") + end_t = mktimestamp(end_time).replace(".", ",") + return f"{idx}\n{start_t} --> {end_t}\n{sub_text}\n" + + return formatter + + +def _match_script_line(script_lines: list[str], current_text: str, sub_index: int) -> str: + """ + 尝试把当前累计的字幕文本,与脚本中的某一条标准断句匹配起来。 + + 这里复用了项目原有的“按标点拆脚本,再逐段比对”的思路: + 1. 优先精确匹配; + 2. 再做一次去常规标点后的匹配; + 3. 最后做一次更激进的非单词字符清洗匹配。 + + 这样可以兼容: + - TTS 返回里可能缺失或单独拆分的标点; + - 中文场景下词边界和脚本文本不完全一一对应的情况。 + """ + if len(script_lines) <= sub_index: + return "" + + target_line = script_lines[sub_index] + if current_text == target_line: + return target_line.strip() + + current_text_normalized = re.sub(r"[^\w\s]", "", current_text) + target_line_normalized = re.sub(r"[^\w\s]", "", target_line) + if current_text_normalized == target_line_normalized: + return target_line.strip() + + current_text_normalized = re.sub(r"\W+", "", current_text) + target_line_normalized = re.sub(r"\W+", "", target_line) + if current_text_normalized == target_line_normalized: + return target_line.strip() + + return "" + + +def _write_subtitle_items(sub_items: list[str], subtitle_file: str) -> bool: + """ + 将已经聚合好的字幕段写入到 SRT 文件,并做一次基本可读性验证。 + + 返回值: + - `True`:字幕文件成功落盘且可被 moviepy 解析; + - `False`:字幕文件写入或解析失败。 + """ + try: + ensure_file_path_exists(subtitle_file) + with open(subtitle_file, "w", encoding="utf-8") as file: + file.write("\n".join(sub_items) + "\n") + + sbs = subtitles.file_to_subtitles(subtitle_file, encoding="utf-8") + duration = max([tb for ((ta, tb), txt) in sbs]) if sbs else 0 + logger.info( + f"completed, subtitle file created: {subtitle_file}, duration: {duration}" + ) + return True + except Exception as e: + logger.error(f"failed, error: {str(e)}") + if os.path.exists(subtitle_file): + os.remove(subtitle_file) + return False + + +def _build_subtitle_items_from_edge_cues( + sub_maker: SubMaker, script_lines: list[str] +) -> list[str]: + """ + 将 edge_tts 7.x 的细粒度 `cues` 聚合为按脚本断句的 SRT 片段。 + + 背景: + edge_tts 7.x 的 `SubMaker.get_srt()` 更偏向逐词/逐短语的时间轴。 + 对英文做逐词高亮尚可,但中文短视频字幕如果直接照搬,会出现 + “金钱 / 是 / 一种 / 社会 / 工具” 这种阅读体验很差的效果。 + + 实现策略: + 1. 逐个消费 cues 中的 `content`; + 2. 累积成一段候选文本; + 3. 当候选文本与脚本里当前目标断句匹配时,收敛为一个完整字幕段; + 4. 使用第一条 cue 的开始时间和最后一条 cue 的结束时间,保证时间轴连续。 + """ + formatter = _build_subtitle_formatter() + sub_items = [] + sub_index = 0 + current_text = "" + current_start_time = None + + for cue in sub_maker.cues: + cue_text = unescape(cue.content) + if current_start_time is None: + current_start_time = int(cue.start.total_seconds() * 10000000) + + current_end_time = int(cue.end.total_seconds() * 10000000) + current_text += cue_text + + matched_text = _match_script_line(script_lines, current_text, sub_index) + if not matched_text: + continue + + sub_index += 1 + sub_items.append( + formatter( + idx=sub_index, + start_time=current_start_time, + end_time=current_end_time, + sub_text=matched_text, + ) + ) + current_text = "" + current_start_time = None + + if current_text.strip(): + logger.warning( + f"edge cues still have unmatched text after aggregation: {current_text}" + ) + + return sub_items + + +def _build_subtitle_items_from_legacy_submaker( + sub_maker: SubMaker, script_lines: list[str] +) -> list[str]: + """ + 将项目原有 `subs/offset` 结构聚合为按脚本断句的 SRT 片段。 + + 这部分保留了原来的核心思路,只是拆成独立函数,便于与 edge_tts 7.x + 的 cues 聚合逻辑共享同一套断句匹配与落盘流程。 + """ + formatter = _build_subtitle_formatter() + start_time = -1.0 + sub_items = [] + sub_index = 0 + sub_line = "" + + legacy_offsets = getattr(sub_maker, "offset", []) + legacy_subs = getattr(sub_maker, "subs", []) + for _, (offset, sub) in enumerate(zip(legacy_offsets, legacy_subs)): + current_start_time, current_end_time = offset + if start_time < 0: + start_time = current_start_time + + sub_line += unescape(sub) + matched_text = _match_script_line(script_lines, sub_line, sub_index) + if not matched_text: + continue + + sub_index += 1 + sub_items.append( + formatter( + idx=sub_index, + start_time=start_time, + end_time=current_end_time, + sub_text=matched_text, + ) + ) + start_time = -1.0 + sub_line = "" + + if sub_line.strip(): + logger.warning( + f"legacy subtitle items still have unmatched text after aggregation: {sub_line}" + ) + + return sub_items + + +def create_subtitle(sub_maker: SubMaker, text: str, subtitle_file: str): + """ + 优化字幕文件 + 1. 将字幕文件按照标点符号分割成多行 + 2. 逐行匹配字幕文件中的文本 + 3. 生成新的字幕文件 + """ + text = _format_text(text) + script_lines = utils.split_string_by_punctuations(text) + try: + if hasattr(sub_maker, "cues") and sub_maker.cues: + sub_items = _build_subtitle_items_from_edge_cues(sub_maker, script_lines) + else: + sub_items = _build_subtitle_items_from_legacy_submaker( + sub_maker, script_lines + ) + + if len(sub_items) != len(script_lines): + logger.warning( + f"failed, sub_items len: {len(sub_items)}, script_lines len: {len(script_lines)}" + ) + return + + _write_subtitle_items(sub_items, subtitle_file) + except Exception as e: + logger.error(f"failed, error: {str(e)}") + + +def _get_audio_duration_from_submaker(sub_maker: SubMaker): + """ + 获取音频时长 + """ + # 优先兼容 edge_tts 7.x 的 cues 结构; + # 如果是项目里其他 TTS 手工填充的旧结构,则继续读取 offset。 + if hasattr(sub_maker, "cues") and sub_maker.cues: + return sub_maker.cues[-1].end.total_seconds() + + legacy_offsets = getattr(sub_maker, "offset", []) + if not legacy_offsets: + return 0.0 + return legacy_offsets[-1][1] / 10000000 + +def _get_audio_duration_from_mp3(mp3_file: str) -> float: + """ + 获取MP3音频时长 + """ + if not os.path.exists(mp3_file): + logger.error(f"MP3 file does not exist: {mp3_file}") + return 0.0 + + try: + # Use moviepy to get the duration of the MP3 file + with AudioFileClip(mp3_file) as audio: + return audio.duration # Duration in seconds + except Exception as e: + logger.error(f"Failed to get audio duration from MP3: {str(e)}") + return 0.0 + +def get_audio_duration(target: Union[str, SubMaker]) -> float: + """ + 获取音频时长 + 如果是SubMaker对象,则从SubMaker中获取时长 + 如果是MP3文件,则从MP3文件中获取时长 + """ + if isinstance(target, SubMaker): + return _get_audio_duration_from_submaker(target) + elif isinstance(target, str) and target.endswith(".mp3"): + return _get_audio_duration_from_mp3(target) + else: + logger.error(f"Invalid target type: {type(target)}") + return 0.0 + +if __name__ == "__main__": + voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female" + voice_name = parse_voice_name(voice_name) + voice_name = is_azure_v2_voice(voice_name) + print(voice_name) + + voices = get_all_azure_voices() + print(len(voices)) + + async def _do(): + temp_dir = utils.storage_dir("temp") + + voice_names = [ + "zh-CN-XiaoxiaoMultilingualNeural", + # 女性 + "zh-CN-XiaoxiaoNeural", + "zh-CN-XiaoyiNeural", + # 男性 + "zh-CN-YunyangNeural", + "zh-CN-YunxiNeural", + ] + text = """ + 静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人,表达了他对家乡和亲人的深深思念之情。全诗内容是:“床前明月光,疑是地上霜。举头望明月,低头思故乡。”在这短短的四句诗中,诗人通过“明月”和“思故乡”的意象,巧妙地表达了离乡背井人的孤独与哀愁。首句“床前明月光”设景立意,通过明亮的月光引出诗人的遐想;“疑是地上霜”增添了夜晚的寒冷感,加深了诗人的孤寂之情;“举头望明月”和“低头思故乡”则是情感的升华,展现了诗人内心深处的乡愁和对家的渴望。这首诗简洁明快,情感真挚,是中国古典诗歌中非常著名的一首,也深受后人喜爱和推崇。 + """ + + text = """ + What is the meaning of life? This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries. Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life. Some say it's to seek happiness and self-fulfillment, while others believe it's about contributing to the welfare of others and making a positive impact in the world. Despite the myriad of perspectives, one thing remains clear: the meaning of life is a deeply personal concept that varies from one person to another. It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence. + """ + + text = """ + 预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具; + 10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉; + 12日天气短暂好转,早晚清凉; + """ + + text = "[Opening scene: A sunny day in a suburban neighborhood. A young boy named Alex, around 8 years old, is playing in his front yard with his loyal dog, Buddy.]\n\n[Camera zooms in on Alex as he throws a ball for Buddy to fetch. Buddy excitedly runs after it and brings it back to Alex.]\n\nAlex: Good boy, Buddy! You're the best dog ever!\n\n[Buddy barks happily and wags his tail.]\n\n[As Alex and Buddy continue playing, a series of potential dangers loom nearby, such as a stray dog approaching, a ball rolling towards the street, and a suspicious-looking stranger walking by.]\n\nAlex: Uh oh, Buddy, look out!\n\n[Buddy senses the danger and immediately springs into action. He barks loudly at the stray dog, scaring it away. Then, he rushes to retrieve the ball before it reaches the street and gently nudges it back towards Alex. Finally, he stands protectively between Alex and the stranger, growling softly to warn them away.]\n\nAlex: Wow, Buddy, you're like my superhero!\n\n[Just as Alex and Buddy are about to head inside, they hear a loud crash from a nearby construction site. They rush over to investigate and find a pile of rubble blocking the path of a kitten trapped underneath.]\n\nAlex: Oh no, Buddy, we have to help!\n\n[Buddy barks in agreement and together they work to carefully move the rubble aside, allowing the kitten to escape unharmed. The kitten gratefully nuzzles against Buddy, who responds with a friendly lick.]\n\nAlex: We did it, Buddy! We saved the day again!\n\n[As Alex and Buddy walk home together, the sun begins to set, casting a warm glow over the neighborhood.]\n\nAlex: Thanks for always being there to watch over me, Buddy. You're not just my dog, you're my best friend.\n\n[Buddy barks happily and nuzzles against Alex as they disappear into the sunset, ready to face whatever adventures tomorrow may bring.]\n\n[End scene.]" + + text = "大家好,我是乔哥,一个想帮你把信用卡全部还清的家伙!\n今天我们要聊的是信用卡的取现功能。\n你是不是也曾经因为一时的资金紧张,而拿着信用卡到ATM机取现?如果是,那你得好好看看这个视频了。\n现在都2024年了,我以为现在不会再有人用信用卡取现功能了。前几天一个粉丝发来一张图片,取现1万。\n信用卡取现有三个弊端。\n一,信用卡取现功能代价可不小。会先收取一个取现手续费,比如这个粉丝,取现1万,按2.5%收取手续费,收取了250元。\n二,信用卡正常消费有最长56天的免息期,但取现不享受免息期。从取现那一天开始,每天按照万5收取利息,这个粉丝用了11天,收取了55元利息。\n三,频繁的取现行为,银行会认为你资金紧张,会被标记为高风险用户,影响你的综合评分和额度。\n那么,如果你资金紧张了,该怎么办呢?\n乔哥给你支一招,用破思机摩擦信用卡,只需要少量的手续费,而且还可以享受最长56天的免息期。\n最后,如果你对玩卡感兴趣,可以找乔哥领取一本《卡神秘籍》,用卡过程中遇到任何疑惑,也欢迎找乔哥交流。\n别忘了,关注乔哥,回复用卡技巧,免费领取《2024用卡技巧》,让我们一起成为用卡高手!" + + text = """ + 2023全年业绩速览 +公司全年累计实现营业收入1476.94亿元,同比增长19.01%,归母净利润747.34亿元,同比增长19.16%。EPS达到59.49元。第四季度单季,营业收入444.25亿元,同比增长20.26%,环比增长31.86%;归母净利润218.58亿元,同比增长19.33%,环比增长29.37%。这一阶段 +的业绩表现不仅突显了公司的增长动力和盈利能力,也反映出公司在竞争激烈的市场环境中保持了良好的发展势头。 +2023年Q4业绩速览 +第四季度,营业收入贡献主要增长点;销售费用高增致盈利能力承压;税金同比上升27%,扰动净利率表现。 +业绩解读 +利润方面,2023全年贵州茅台,>归母净利润增速为19%,其中营业收入正贡献18%,营业成本正贡献百分之一,管理费用正贡献百分之一点四。(注:归母净利润增速值=营业收入增速+各科目贡献,展示贡献/拖累的前四名科目,且要求贡献值/净利润增速>15%) +""" + text = "静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人" + + text = _format_text(text) + lines = utils.split_string_by_punctuations(text) + print(lines) + + for voice_name in voice_names: + voice_file = f"{temp_dir}/tts-{voice_name}.mp3" + subtitle_file = f"{temp_dir}/tts.mp3.srt" + sub_maker = azure_tts_v2( + text=text, voice_name=voice_name, voice_file=voice_file + ) + create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file) + audio_duration = get_audio_duration(sub_maker) + print(f"voice: {voice_name}, audio duration: {audio_duration}s") + + loop = asyncio.get_event_loop_policy().get_event_loop() + try: + loop.run_until_complete(_do()) + finally: + loop.close() diff --git a/app/utils/file_security.py b/app/utils/file_security.py new file mode 100644 index 0000000..ce694cb --- /dev/null +++ b/app/utils/file_security.py @@ -0,0 +1,39 @@ +import os +from asyncio import log + + +def resolve_path_within_directory( + base_dir: str, + unsafe_path: str, + *, + require_file: bool = True, +) -> str: + # 用户传入的路径可能是文件名、相对路径、绝对路径,也可能夹带 `../`。 + # 这里统一解析成真实路径,并用 commonpath 判断它是否仍在允许目录内。 + # 这样比简单判断字符串前缀可靠,可以覆盖符号链接、重复分隔符、相对路径 + # 等场景,适用于上传目录、素材目录、任务产物目录这类白名单目录。 + if not unsafe_path: + raise ValueError("empty path is not allowed") + + base_dir_real = os.path.realpath(base_dir) + candidate_path = unsafe_path + + if not os.path.isabs(candidate_path): + candidate_path = os.path.join(base_dir_real, candidate_path) + + + resolved_path = os.path.realpath(candidate_path) + + try: + common_path = os.path.commonpath([base_dir_real, resolved_path]) + except ValueError as exc: + # Windows 下不同盘符会触发 ValueError,这类路径一定不属于允许目录。 + raise ValueError("path is outside the allowed directory") from exc + + if common_path != base_dir_real: + raise ValueError("path is outside the allowed directory") + + if require_file and not os.path.isfile(resolved_path): + raise ValueError("file does not exist") + + return resolved_path diff --git a/app/utils/utils.py b/app/utils/utils.py new file mode 100644 index 0000000..86159da --- /dev/null +++ b/app/utils/utils.py @@ -0,0 +1,236 @@ +import json +import locale +import os +from pathlib import Path +import threading +from typing import Any +from uuid import uuid4 + +from loguru import logger + +from app.models import const + + +def get_response(status: int, data: Any = None, message: str = ""): + obj = { + "status": status, + } + if data: + obj["data"] = data + if message: + obj["message"] = message + return obj + + +def to_json(obj): + try: + # Define a helper function to handle different types of objects + def serialize(o): + # If the object is a serializable type, return it directly + if isinstance(o, (int, float, bool, str)) or o is None: + return o + # If the object is binary data, convert it to a base64-encoded string + elif isinstance(o, bytes): + return "*** binary data ***" + # If the object is a dictionary, recursively process each key-value pair + elif isinstance(o, dict): + return {k: serialize(v) for k, v in o.items()} + # If the object is a list or tuple, recursively process each element + elif isinstance(o, (list, tuple)): + return [serialize(item) for item in o] + # If the object is a custom type, attempt to return its __dict__ attribute + elif hasattr(o, "__dict__"): + return serialize(o.__dict__) + # Return None for other cases (or choose to raise an exception) + else: + return None + + # Use the serialize function to process the input object + serialized_obj = serialize(obj) + + # Serialize the processed object into a JSON string + return json.dumps(serialized_obj, ensure_ascii=False, indent=4) + except Exception as e: + logger.error(f"failed to serialize object to json: {str(e)}") + return None + + +def get_uuid(remove_hyphen: bool = False): + u = str(uuid4()) + if remove_hyphen: + u = u.replace("-", "") + return u + + +def root_dir(): + return os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) + + +def storage_dir(sub_dir: str = "", create: bool = False): + d = os.path.join(root_dir(), "storage") + if sub_dir: + d = os.path.join(d, sub_dir) + if create and not os.path.exists(d): + os.makedirs(d) + + return d + + +def resource_dir(sub_dir: str = ""): + d = os.path.join(root_dir(), "resource") + if sub_dir: + d = os.path.join(d, sub_dir) + return d + + +def task_dir(sub_dir: str = ""): + d = os.path.join(storage_dir(), "tasks") + if sub_dir: + d = os.path.join(d, sub_dir) + if not os.path.exists(d): + os.makedirs(d) + return d + + +def font_dir(sub_dir: str = ""): + d = resource_dir("fonts") + if sub_dir: + d = os.path.join(d, sub_dir) + if not os.path.exists(d): + os.makedirs(d) + return d + + +def song_dir(sub_dir: str = ""): + d = resource_dir("songs") + if sub_dir: + d = os.path.join(d, sub_dir) + if not os.path.exists(d): + os.makedirs(d) + return d + + +def public_dir(sub_dir: str = ""): + d = resource_dir("public") + if sub_dir: + d = os.path.join(d, sub_dir) + if not os.path.exists(d): + os.makedirs(d) + return d + + +def run_in_background(func, *args, **kwargs): + def run(): + try: + func(*args, **kwargs) + except Exception as e: + logger.error(f"run_in_background error: {e}", exc_info=True) + + thread = threading.Thread(target=run, daemon=False) + thread.start() + return thread + + +def time_convert_seconds_to_hmsm(seconds) -> str: + hours = int(seconds // 3600) + seconds = seconds % 3600 + minutes = int(seconds // 60) + milliseconds = int(seconds * 1000) % 1000 + seconds = int(seconds % 60) + return "{:02d}:{:02d}:{:02d},{:03d}".format(hours, minutes, seconds, milliseconds) + + +def text_to_srt(idx: int, msg: str, start_time: float, end_time: float) -> str: + start_time = time_convert_seconds_to_hmsm(start_time) + end_time = time_convert_seconds_to_hmsm(end_time) + srt = """%d +%s --> %s +%s + """ % ( + idx, + start_time, + end_time, + msg, + ) + return srt + + +def str_contains_punctuation(word): + for p in const.PUNCTUATIONS: + if p in word: + return True + return False + + +def split_string_by_punctuations(s): + result = [] + txt = "" + + previous_char = "" + next_char = "" + for i in range(len(s)): + char = s[i] + if char == "\n": + result.append(txt.strip()) + txt = "" + continue + + if i > 0: + previous_char = s[i - 1] + if i < len(s) - 1: + next_char = s[i + 1] + + if char == "." and previous_char.isdigit() and next_char.isdigit(): + # # In the case of "withdraw 10,000, charged at 2.5% fee", the dot in "2.5" should not be treated as a line break marker + txt += char + continue + + if char == "," and previous_char.isdigit() and next_char.isdigit(): + # 英文数字里的千分位逗号不是断句符,例如 "1,000 years"。 + # Edge TTS 的 word boundary 通常会把这种数字整体作为连续内容返回; + # 如果这里拆成 "1" 和 "000 years",后续字幕聚合会无法匹配脚本原文, + # 进而错误回退到 Whisper。 + txt += char + continue + + if char not in const.PUNCTUATIONS: + txt += char + else: + result.append(txt.strip()) + txt = "" + result.append(txt.strip()) + # filter empty string + result = list(filter(None, result)) + return result + + +def md5(text): + import hashlib + + return hashlib.md5(text.encode("utf-8")).hexdigest() + + +def get_system_locale(): + try: + loc = locale.getdefaultlocale() + # zh_CN, zh_TW return zh + # en_US, en_GB return en + language_code = loc[0].split("_")[0] + return language_code + except Exception: + return "en" + + +def load_locales(i18n_dir): + _locales = {} + for root, dirs, files in os.walk(i18n_dir): + for file in files: + if file.endswith(".json"): + lang = file.split(".")[0] + with open(os.path.join(root, file), "r", encoding="utf-8") as f: + _locales[lang] = json.loads(f.read()) + return _locales + + +def parse_extension(filename): + return Path(filename).suffix.lower().lstrip('.') diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..59a37b3 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,317 @@ +[app] +video_source = "pexels" # "pexels" or "pixabay" + +# 是否隐藏配置面板 +hide_config = false + +# Azure TTS V1(edge_tts) 单次流式请求超时时间,单位秒。 +# 网络异常、服务端限流或 voice 与文本语言不匹配时,edge_tts 可能长期卡住。 +# 默认 30 秒可避免 WebUI 任务无反馈;慢网络或代理环境可适当调大。 +# 设置为 0 表示禁用超时。 +edge_tts_timeout = 30 + +# 是否校验外部 API 和素材下载的 TLS 证书。 +# 默认必须开启,避免 Pexels/Pixabay API key 和下载素材被中间人攻击篡改。 +# 只有在企业代理或自签证书环境明确需要时,才临时改为 false。 +tls_verify = true + +# Pexels API Key +# Register at https://www.pexels.com/api/ to get your API key. +# You can use multiple keys to avoid rate limits. +# For example: pexels_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"] +# 特别注意格式,Key 用英文双引号括起来,多个Key用逗号隔开 +pexels_api_keys = [] + +# Pixabay API Key +# Register at https://pixabay.com/api/docs/ to get your API key. +# You can use multiple keys to avoid rate limits. +# For example: pixabay_api_keys = ["123adsf4567adf89","abd1321cd13efgfdfhi"] +# 特别注意格式,Key 用英文双引号括起来,多个Key用逗号隔开 +pixabay_api_keys = [] + +# 支持的提供商 (Supported providers): +# openai +# moonshot (月之暗面) +# azure +# qwen (通义千问) +# deepseek +# gemini +# ollama +# g4f (disabled by default; set enable_g4f=true only after accepting its risks) +# oneapi +# cloudflare +# minimax +# mimo (Xiaomi MiMo) +# ernie (文心一言) +# modelscope (魔搭社区) +# litellm (100+ providers via LiteLLM gateway) +llm_provider = "openai" + +########## Pollinations AI Settings +# Visit https://pollinations.ai/ to learn more +# API Key is optional - leave empty for public access +pollinations_api_key = "" +# Default base URL for Pollinations API +pollinations_base_url = "https://pollinations.ai/api/v1" +# Default model for text generation +pollinations_model_name = "openai-fast" + +########## Ollama Settings +# No need to set it unless you want to use your own proxy +ollama_base_url = "" +# Check your available models at https://ollama.com/library +ollama_model_name = "" + +########## OpenAI API Key +# Get your API key at https://platform.openai.com/api-keys +# 也可以填写兼容 OpenAI Chat Completions 协议的供应商密钥, +# 例如 OpenRouter 这类提供自定义 base_url 的平台。 +openai_api_key = "" +# No need to set it unless you want to use your own proxy +# 如果使用兼容 OpenAI 接口的平台,请在这里填写对应的 base_url。 +openai_base_url = "" +# Check your available models at https://platform.openai.com/account/limits +# 如果使用兼容供应商,请填写该平台要求的模型 ID。 +openai_model_name = "gpt-4o-mini" + +########## Moonshot API Key +# Visit https://platform.moonshot.cn/console/api-keys to get your API key. +moonshot_api_key = "" +moonshot_base_url = "https://api.moonshot.cn/v1" +moonshot_model_name = "moonshot-v1-8k" + +########## OneAPI API Key +# Visit https://github.com/songquanpeng/one-api to get your API key +oneapi_api_key = "" +oneapi_base_url = "" +oneapi_model_name = "" + +########## G4F +# g4f relies on reverse-engineered third-party endpoints and is not recommended +# for production. Prefer official APIs, OpenAI-compatible providers, LiteLLM, +# Ollama, or local inference. +# If you still need it, install the optional dependency with: +# uv sync --extra g4f +enable_g4f = false +# Visit https://github.com/xtekky/gpt4free to get more details +# Supported model list: https://github.com/xtekky/gpt4free/blob/main/g4f/models.py +g4f_model_name = "gpt-3.5-turbo" + +########## Azure API Key +# Visit https://learn.microsoft.com/zh-cn/azure/ai-services/openai/ to get more details +# API documentation: https://learn.microsoft.com/zh-cn/azure/ai-services/openai/reference +azure_api_key = "" +azure_base_url = "" +azure_model_name = "gpt-35-turbo" # replace with your model deployment name +azure_api_version = "2024-02-15-preview" + +########## Gemini API Key +gemini_api_key = "" +gemini_model_name = "gemini-2.5-flash" + +########## Grok API Key +grok_api_key = "" +grok_model_name = "grok-4.3" +grok_base_url = "https://api.x.ai/v1" + +########## Qwen API Key +# Visit https://dashscope.console.aliyun.com/apiKey to get your API key +# Visit below links to get more details +# https://tongyi.aliyun.com/qianwen/ +# https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction +qwen_api_key = "" +qwen_model_name = "qwen-max" + + +########## MiniMax API Key +# Visit https://platform.minimax.io to get your API key +# MiniMax API is OpenAI-compatible +# Available models: MiniMax-M2.7 (default), MiniMax-M2.7-highspeed +minimax_api_key = "" +minimax_base_url = "https://api.minimax.io/v1" +minimax_model_name = "MiniMax-M2.7" + +########## Xiaomi MiMo API Key +# Xiaomi MiMo API is OpenAI-compatible. +# Visit https://platform.xiaomimimo.com/docs/zh-CN/quick-start/first-api-call to get more details. +mimo_api_key = "" +mimo_base_url = "https://api.xiaomimimo.com/v1" +mimo_model_name = "mimo-v2.5-pro" +# Xiaomi MiMo TTS uses the same API key and base_url as MiMo LLM. +mimo_tts_model_name = "mimo-v2.5-tts" +# Optional natural language style prompt for MiMo TTS. +mimo_tts_style_prompt = "请用自然、清晰、适合短视频旁白的语气朗读。" + +########## DeepSeek API Key +# Visit https://platform.deepseek.com/api_keys to get your API key +deepseek_api_key = "" +deepseek_base_url = "https://api.deepseek.com" +deepseek_model_name = "deepseek-chat" + + +########## ModelScope API Key +# Visit https://modelscope.cn/docs/model-service/API-Inference/intro to get your API key +# And note that you need to bind your Alibaba Cloud account before using the API. +modelscope_api_key = "" +modelscope_base_url = "https://api-inference.modelscope.cn/v1/" +modelscope_model_name = "Qwen/Qwen3-32B" + +########## LiteLLM (AI Gateway — 100+ providers) +# LiteLLM routes to any LLM provider via a unified interface. +# Use this for providers not directly supported above: Anthropic (native API), +# AWS Bedrock, Google Vertex AI, Cohere, Mistral, Together AI, etc. +# Visit https://docs.litellm.ai/docs/providers for the full list. +# +# LiteLLM reads provider API keys from environment variables automatically: +# OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AWS_ACCESS_KEY_ID, etc. +# Set the appropriate env var for your chosen provider before running. +# +# Model name in LiteLLM format: "provider/model-name" +# Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514", +# "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0", +# "gemini/gemini-2.5-flash", "ollama/llama3" +litellm_model_name = "openai/gpt-4o-mini" + +# Subtitle Provider, "edge" or "whisper" +# If empty, the subtitle will not be generated +subtitle_provider = "edge" + +# +# ImageMagick +# +# Once you have installed it, ImageMagick will be automatically detected, except on Windows! +# On Windows, for example "C:\Program Files (x86)\ImageMagick-7.1.1-Q16-HDRI\magick.exe" +# Download from https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-29-Q16-x64-static.exe + +# imagemagick_path = "C:\\Program Files (x86)\\ImageMagick-7.1.1-Q16\\magick.exe" + + +# +# FFMPEG +# +# 通常情况下,ffmpeg 会被自动下载,并且会被自动检测到。 +# 但是如果你的环境有问题,无法自动下载,可能会遇到如下错误: +# RuntimeError: No ffmpeg exe could be found. +# Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. +# 此时你可以手动下载 ffmpeg 并设置 ffmpeg_path,下载地址:https://www.gyan.dev/ffmpeg/builds/ + +# Under normal circumstances, ffmpeg is downloaded automatically and detected automatically. +# However, if there is an issue with your environment that prevents automatic downloading, you might encounter the following error: +# RuntimeError: No ffmpeg exe could be found. +# Install ffmpeg on your system, or set the IMAGEIO_FFMPEG_EXE environment variable. +# In such cases, you can manually download ffmpeg and set the ffmpeg_path, download link: https://www.gyan.dev/ffmpeg/builds/ + +# ffmpeg_path = "C:\\Users\\harry\\Downloads\\ffmpeg.exe" +######################################################################################### + +# 当视频生成成功后,API服务提供的视频下载接入点,默认为当前服务的地址和监听端口 +# 比如 http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 +# 如果你需要使用域名对外提供服务(一般会用nginx做代理),则可以设置为你的域名 +# 比如 https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 +# endpoint="https://xxxx.com" + +# When the video is successfully generated, the API service provides a download endpoint for the video, defaulting to the service's current address and listening port. +# For example, http://127.0.0.1:8080/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 +# If you need to provide the service externally using a domain name (usually done with nginx as a proxy), you can set it to your domain name. +# For example, https://xxxx.com/tasks/6357f542-a4e1-46a1-b4c9-bf3bd0df5285/final-1.mp4 +# endpoint="https://xxxx.com" +endpoint = "" + + +# Video material storage location +# material_directory = "" # Indicates that video materials will be downloaded to the default folder, the default folder is ./storage/cache_videos under the current project +# material_directory = "/user/harry/videos" # Indicates that video materials will be downloaded to a specified folder +# material_directory = "task" # Indicates that video materials will be downloaded to the current task's folder, this method does not allow sharing of already downloaded video materials + +# 视频素材存放位置 +# material_directory = "" #表示将视频素材下载到默认的文件夹,默认文件夹为当前项目下的 ./storage/cache_videos +# material_directory = "/user/harry/videos" #表示将视频素材下载到指定的文件夹中 +# material_directory = "task" #表示将视频素材下载到当前任务的文件夹中,这种方式无法共享已经下载的视频素材 + +material_directory = "" + +# Used for state management of the task +enable_redis = false +redis_host = "localhost" +redis_port = 6379 +redis_db = 0 +redis_password = "" + +# 文生视频时的最大并发任务数 +max_concurrent_tasks = 5 + +# 并发任务已满后的最大排队任务数,超过后接口返回 429,避免匿名请求无限堆积 +max_queued_tasks = 100 + + +[whisper] +# Only effective when subtitle_provider is "whisper" + +# Run on GPU with FP16 +# model = WhisperModel(model_size, device="cuda", compute_type="float16") + +# Run on GPU with INT8 +# model = WhisperModel(model_size, device="cuda", compute_type="int8_float16") + +# Run on CPU with INT8 +# model = WhisperModel(model_size, device="cpu", compute_type="int8") + +# recommended model_size: "large-v3" +model_size = "large-v3" +# if you want to use GPU, set device="cuda" +device = "CPU" +compute_type = "int8" + + +[proxy] +### Use a proxy to access the Pexels API +### Format: "http://:@:" +### Example: "http://user:pass@proxy:1234" +### Doc: https://requests.readthedocs.io/en/latest/user/advanced/#proxies + +# http = "http://10.10.1.10:3128" +# https = "http://10.10.1.10:1080" + +[azure] +# Azure Speech API Key +# Get your API key at https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices +speech_key = "" +speech_region = "" + +[siliconflow] +# SiliconFlow API Key +# Get your API key at https://siliconflow.cn +api_key = "" + +[ui] +# UI related settings +# 是否隐藏日志信息 +# Whether to hide logs in the UI +hide_log = false + +# 字幕位置设置 (Subtitle position settings) +# 可选值 (Available values): "top", "center", "bottom", "custom" +# subtitle_position = "custom" +# 自定义位置,表示离顶部的百分比 (0-100),仅当 subtitle_position = "custom" 时生效 +# Custom position as percentage from top (0-100), only effective when subtitle_position = "custom" +# custom_position = 70.0 + +########## Upload-Post (Cross-post to TikTok/Instagram) +# Upload-Post allows you to automatically cross-post generated videos to TikTok and Instagram. +# Visit https://upload-post.com to create an account and get your API key. +# API Documentation: https://docs.upload-post.com + +# Enable/disable Upload-Post integration +upload_post_enabled = false + +# Your Upload-Post API key +upload_post_api_key = "" + +# Your Upload-Post username +upload_post_username = "" + +# Platforms to cross-post to (options: "tiktok", "instagram") +upload_post_platforms = ["tiktok", "instagram"] + +# Automatically cross-post videos after generation (if false, you'll need to call the API manually) +upload_post_auto_upload = false diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 0000000..a15ea01 --- /dev/null +++ b/docker-compose.gpu.yml @@ -0,0 +1,27 @@ +# GPU override file +# Usage: docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d +# +# Prerequisites: +# 1. NVIDIA GPU driver installed on host +# 2. NVIDIA Container Toolkit installed +# 3. Verify with: docker info | grep nvidia +# +# This file overrides the default docker-compose.yml to: +# - Build with Dockerfile.gpu (NVIDIA CUDA base image) +# - Attach GPU device to the api service +services: + webui: + build: + context: . + dockerfile: Dockerfile.gpu + api: + build: + context: . + dockerfile: Dockerfile.gpu + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..acad462 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +x-common-volumes: &common-volumes + - ./:/MoneyPrinterTurbo + +services: + webui: + build: + context: . + dockerfile: Dockerfile + container_name: "moneyprinterturbo-webui" + ports: + - "8501:8501" + command: [ "streamlit", "run", "./webui/Main.py","--browser.serverAddress=127.0.0.1","--server.enableCORS=True","--browser.gatherUsageStats=False" ] + volumes: *common-volumes + restart: always + api: + build: + context: . + dockerfile: Dockerfile + container_name: "moneyprinterturbo-api" + ports: + - "8080:8080" + command: [ "python3", "main.py" ] + volumes: *common-volumes + restart: always \ No newline at end of file diff --git a/docs/GPU_DOCKER_DEPLOYMENT.md b/docs/GPU_DOCKER_DEPLOYMENT.md new file mode 100644 index 0000000..506e203 --- /dev/null +++ b/docs/GPU_DOCKER_DEPLOYMENT.md @@ -0,0 +1,142 @@ +# GPU Docker Deployment Guide + +本文档介绍如何使用 GPU 加速 `faster-whisper` 字幕生成,大幅提升处理速度。 + +## 为什么要 GPU 加速 + +MoneyPrinterTurbo 中唯一的深度学习环节是 **faster-whisper 语音识别**(将音频转为带时间戳的字幕)。 + +- **CPU 模式**(默认):`large-v3` 模型生成字幕较慢 +- **GPU 模式**:利用 NVIDIA GPU + CUDA 加速,速度提升 **5-10 倍** + +> 注意:项目的其他环节(脚本生成、音频合成、视频剪辑)不涉及深度学习,GPU 只加速字幕生成。 + +## 部署方式 + +本项目提供两种 Docker 部署方式,**默认 CPU 部署不受任何影响**: + +### CPU 部署(默认,零变化) + +```bash +docker compose up -d +``` + +使用原有的 `Dockerfile`(`python:3.11-slim-bullseye`),无需 GPU。 + +### GPU 部署(有 NVIDIA GPU 的用户) + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d +``` + +使用 `Dockerfile.gpu`(`nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04`)并为 api 服务挂载 GPU。 + +## GPU 部署前提条件 + +### 1. 硬件要求 + +- NVIDIA GPU(建议 6GB 以上显存) +- `large-v3` 模型在 GPU 上 `float16` 精度约占用 1.5GB 显存 + +### 2. 软件要求 + +- **NVIDIA 驱动**:最新版即可,运行 `nvidia-smi` 确认 +- **Docker Desktop** +- **NVIDIA Container Toolkit**:运行 `docker info` 查看 Runtimes 列表中是否有 `nvidia` + +### 3. 环境验证 + +```bash +# 确认 NVIDIA 驱动正常 +nvidia-smi + +# 确认 Docker 支持 GPU(Runtimes 中应包含 nvidia) +docker info | findstr nvidia +``` + +如果没有 `nvidia` runtime,需要先安装 [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)。 + +## 配置 Whisper 使用 GPU + +在 `config.toml` 中设置: + +```toml +subtitle_provider = "whisper" + +[whisper] +model_size = "large-v3" +device = "cuda" # 使用 GPU(CPU 用户设为 "cpu") +compute_type = "float16" # GPU 推荐 float16(CPU 用户设为 "int8") +``` + +## 文件说明 + +| 文件 | 用途 | +|---|---| +| `Dockerfile` | 默认 CPU 镜像(原有,未修改) | +| `Dockerfile.gpu` | GPU 镜像(新增,基于 NVIDIA CUDA) | +| `docker-compose.yml` | 默认 CPU 部署配置(原有,未修改) | +| `docker-compose.gpu.yml` | GPU 部署覆盖配置(新增) | + +## GPU 部署步骤 + +### 第 1 步:拉取 CUDA 基础镜像 + +```bash +docker pull nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 +``` + +> 如果使用了阿里云等镜像加速源,可能对 `nvidia/cuda` 返回 403。请确保能从 Docker Hub 直接拉取。 + +### 第 2 步:修改 config.toml + +按上文说明设置 `subtitle_provider = "whisper"` 和 `device = "cuda"`。 + +### 第 3 步:构建并启动 + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build +``` + +### 第 4 步:验证 GPU 是否生效 + +```bash +docker exec -it moneyprinterturbo-api nvidia-smi +``` + +如果能看到 GPU 信息,说明 GPU 挂载成功。 + +## 显存与并发建议 + +| GPU 显存 | 建议最大并发任务数 | +|---|---| +| 4GB | 1-2 | +| 6GB | 2-3 | +| 8GB | 3-4 | +| 12GB+ | 5 | + +可通过 `config.toml` 中的 `max_concurrent_tasks` 控制并发数。 + +## 故障排查 + +### 问题 1:镜像拉取失败(403 Forbidden) + +阿里云镜像加速对 `nvidia/cuda` 返回 403。解决方法: +- 配置其他可用的镜像加速源 +- 或直接 `docker pull nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04` + +### 问题 2:pip 安装报 `Cannot uninstall blinker` + +Ubuntu 22.04 系统自带的 `blinker` 通过 `distutils` 安装,pip 无法卸载。`Dockerfile.gpu` 已通过 `apt-get remove -y python3-blinker` 处理。 + +### 问题 3:容器内 `nvidia-smi` 找不到 GPU + +- 确认宿主机已安装 NVIDIA Container Toolkit +- 确认 `docker info` 中 Runtimes 包含 `nvidia` +- 确认使用了 GPU 部署命令:`docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d` + +### 问题 4:Whisper 报 CUDA 错误 + +- 确认 `config.toml` 中 `device = "cuda"`(大小写敏感,不是 `"CPU"`) +- 确认 `compute_type = "float16"` +- 确认 `subtitle_provider = "whisper"` diff --git a/docs/MoneyPrinterTurbo.ipynb b/docs/MoneyPrinterTurbo.ipynb new file mode 100644 index 0000000..4c71710 --- /dev/null +++ b/docs/MoneyPrinterTurbo.ipynb @@ -0,0 +1,121 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# MoneyPrinterTurbo Setup Guide\n", + "\n", + "This notebook will guide you through the process of setting up [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Clone Repository and Install Dependencies\n", + "\n", + "First, we'll clone the repository from GitHub and install all required packages:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "S8Eu-aQarY_B" + }, + "outputs": [], + "source": [ + "!git clone https://github.com/harry0703/MoneyPrinterTurbo.git\n", + "%cd MoneyPrinterTurbo\n", + "!pip install -q uv pyngrok\n", + "!uv python install 3.11\n", + "# Colab 预装了 google-colab / google-genai / google-adk 等包,直接 pip install -r requirements.txt\n", + "# 会覆盖全局依赖并触发版本冲突。这里使用 uv 为项目创建独立 .venv,避免影响 Colab 运行时。\n", + "!uv sync --frozen --python 3.11" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Configure ngrok for Remote Access\n", + "\n", + "We'll use ngrok to create a secure tunnel to expose our local Streamlit server to the internet.\n", + "\n", + "**Important**: You need to get your authentication token from the [ngrok dashboard](https://dashboard.ngrok.com/get-started/your-authtoken) to use this service." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pyngrok import ngrok\n", + "\n", + "# Terminate any existing ngrok tunnels\n", + "ngrok.kill()\n", + "\n", + "# Set your authentication token\n", + "# Replace \"your_ngrok_auth_token\" with your actual token\n", + "ngrok.set_auth_token(\"your_ngrok_auth_token\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Launch Application and Generate Public URL\n", + "\n", + "Now we'll start the Streamlit server and create an ngrok tunnel to make it accessible online:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "oahsIOXmwjl9", + "outputId": "ee23a96c-af21-4207-deb7-9fab69e0c05e" + }, + "outputs": [], + "source": [ + "import subprocess\n", + "import time\n", + "\n", + "print(\"🚀 Starting MoneyPrinterTurbo...\")\n", + "# 通过 uv run 进入项目 .venv,确保 Streamlit 使用上一步锁定的依赖版本。\n", + "streamlit_proc = subprocess.Popen([\n", + " \"uv\", \"run\", \"streamlit\", \"run\", \"./webui/Main.py\", \"--server.port=8501\", \"--server.address=0.0.0.0\"\n", + "])\n", + "\n", + "# Wait for the server to initialize\n", + "time.sleep(5)\n", + "\n", + "print(\"🌐 Creating ngrok tunnel to expose the MoneyPrinterTurbo...\")\n", + "public_url = ngrok.connect(8501, bind_tls=True)\n", + "\n", + "print(\"✅ Deployment complete! Access your MoneyPrinterTurbo at:\")\n", + "print(public_url)" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/docs/PR_MERGE_RECORD_2026-04-02.md b/docs/PR_MERGE_RECORD_2026-04-02.md new file mode 100644 index 0000000..6b6a8cd --- /dev/null +++ b/docs/PR_MERGE_RECORD_2026-04-02.md @@ -0,0 +1,120 @@ +# 2026-04-02 PR 合并与验证记录 + +## 本次已合并并推送的 PR + +- `#837` `fix: update google-generativeai version for response_modalities support` +- `#835` `fix: add missing pydub dependency to requirements.txt` +- `#850` `feat: support reading subtitle position from config file` +- `#838` `feat: add MiniMax as LLM provider` +- `#811` `refactor: optimize codebase for better performance and reliability` +- `#848` `feat: support GPU acceleration for faster-whisper in Docker` +- `#843` `feat: Add Upload-Post integration for cross-posting to TikTok/Instagram` + +## 合并后的主线提交 + +- TTS 与字幕修复基线提交:`953a6c0` `fix: restore edge tts synthesis and readable subtitles` +- 当前主线提交:`1f8a746` + +## 合并时的验证结论 + +### 已通过 + +- `#837` + - 依赖升级后可正常导入 + - `google-generativeai==0.8.6` 已生效 +- `#835` + - `pydub==0.25.1` 已生效 +- `#850` + - `subtitle_position` 与 `custom_position` 可从配置文件读取 +- `#838` + - MiniMax provider 接线正常 + - 使用 mock 调用验证 `_generate_response` 通过 +- `#811` + - 主线导入正常 + - 抽样单测通过 +- `#848` + - `docker compose -f docker-compose.yml -f docker-compose.gpu.yml config` 可正常解析 +- `#843` + - Upload-Post 服务导入和 mock 上传调用通过 + - 与前面 PR 叠加时仅在 `config.example.toml` 存在配置段落冲突,已手工保留两边内容 + +### 已拒绝并关闭 + +- `#852` + - 能恢复音频,但会破坏字幕链路,并删除仍被 WebUI 调用的 Gemini 逻辑 +- `#787` + - 不能解决当前 `403` 场景 +- `#841` + - 与当前主线 TTS/字幕修复冲突,且收益已被更小 PR 覆盖 +- `#824` + - ModelsLab 路径能出音频,但字幕链路失败,无法产出可用 SRT +- `#840` + - 后端加入 `video_source="ai"`,但 WebUI 仍不支持该值,端到端不可用 +- `#826` + - 与当前主线 `voice.py` 和依赖变更冲突,未通过合并验证 +- `#751` +- `#749` +- `#742` +- `#705` + - 以上 4 个 PR 在当前主线下均为 `DIRTY`,未通过合并验证 + +## 冒烟测试记录 + +### 服务重启 + +- API:`http://127.0.0.1:8080/docs` +- WebUI:`http://127.0.0.1:8501` + +### 第一次完整视频任务 + +- 任务号:`ced0b190-dd72-489c-b978-2761740933db` +- 结果:失败 +- 结论: + - API 默认 `video_transition_mode=null` + - 视频拼接阶段在 `app/services/video.py` 中直接访问 `video_transition_mode.value` + - 导致任务线程异常退出,任务状态停留在 `state=4, progress=75` + +### 第二次完整视频任务 + +- 任务号:`8b2a0e6e-b3e6-44ab-a1b4-1865a0b4788d` +- 提交方式: + - `POST /api/v1/videos` + - 使用本地素材 `/Users/harry/Projects/Python/MoneyPrinterTurbo/test/resources/1.png` + - 显式指定 `video_transition_mode="FadeIn"` +- 结果:成功 +- 任务状态:`state=1, progress=100` + +### 第二次任务产物 + +- 音频:`/Users/harry/Projects/Python/MoneyPrinterTurbo/storage/tasks/8b2a0e6e-b3e6-44ab-a1b4-1865a0b4788d/audio.mp3` + - 时长:`8.952s` + - 大小:`53712 bytes` +- 拼接视频:`/Users/harry/Projects/Python/MoneyPrinterTurbo/storage/tasks/8b2a0e6e-b3e6-44ab-a1b4-1865a0b4788d/combined-1.mp4` + - 时长:`9.000s` + - 大小:`177666 bytes` +- 成片:`/Users/harry/Projects/Python/MoneyPrinterTurbo/storage/tasks/8b2a0e6e-b3e6-44ab-a1b4-1865a0b4788d/final-1.mp4` + - 时长:`9.000s` + - 大小:`352810 bytes` +- 字幕:`/Users/harry/Projects/Python/MoneyPrinterTurbo/storage/tasks/8b2a0e6e-b3e6-44ab-a1b4-1865a0b4788d/subtitle.srt` + +### 第二次任务字幕样本 + +```srt +1 +00:00:00,100 --> 00:00:03,300 +这是一次主线合并后的完整冒烟测试 + +2 +00:00:03,875 --> 00:00:05,350 +我们要确认语音 + +3 +00:00:05,575 --> 00:00:08,375 +字幕和视频成片都能正常生成 +``` + +## 当前仍需关注的风险 + +- `#843` 仅做了 mock 验证,尚未使用真实 Upload-Post 密钥联调 +- `#848` 仅验证了 Docker GPU 配置解析,尚未在真实 GPU 环境运行 +- 当前 API 默认 `video_transition_mode=null` 时,完整视频任务仍存在回归风险 diff --git a/docs/api.jpg b/docs/api.jpg new file mode 100644 index 0000000..769dc79 Binary files /dev/null and b/docs/api.jpg differ diff --git a/docs/picwish.com.jpg b/docs/picwish.com.jpg new file mode 100644 index 0000000..db086ad Binary files /dev/null and b/docs/picwish.com.jpg differ diff --git a/docs/picwish.jpg b/docs/picwish.jpg new file mode 100644 index 0000000..d60d03a Binary files /dev/null and b/docs/picwish.jpg differ diff --git a/docs/reccloud.cn.jpg b/docs/reccloud.cn.jpg new file mode 100644 index 0000000..733111d Binary files /dev/null and b/docs/reccloud.cn.jpg differ diff --git a/docs/reccloud.com.jpg b/docs/reccloud.com.jpg new file mode 100644 index 0000000..043e521 Binary files /dev/null and b/docs/reccloud.com.jpg differ diff --git a/docs/voice-list.txt b/docs/voice-list.txt new file mode 100644 index 0000000..4672117 --- /dev/null +++ b/docs/voice-list.txt @@ -0,0 +1,941 @@ +Name: af-ZA-AdriNeural +Gender: Female + +Name: af-ZA-WillemNeural +Gender: Male + +Name: am-ET-AmehaNeural +Gender: Male + +Name: am-ET-MekdesNeural +Gender: Female + +Name: ar-AE-FatimaNeural +Gender: Female + +Name: ar-AE-HamdanNeural +Gender: Male + +Name: ar-BH-AliNeural +Gender: Male + +Name: ar-BH-LailaNeural +Gender: Female + +Name: ar-DZ-AminaNeural +Gender: Female + +Name: ar-DZ-IsmaelNeural +Gender: Male + +Name: ar-EG-SalmaNeural +Gender: Female + +Name: ar-EG-ShakirNeural +Gender: Male + +Name: ar-IQ-BasselNeural +Gender: Male + +Name: ar-IQ-RanaNeural +Gender: Female + +Name: ar-JO-SanaNeural +Gender: Female + +Name: ar-JO-TaimNeural +Gender: Male + +Name: ar-KW-FahedNeural +Gender: Male + +Name: ar-KW-NouraNeural +Gender: Female + +Name: ar-LB-LaylaNeural +Gender: Female + +Name: ar-LB-RamiNeural +Gender: Male + +Name: ar-LY-ImanNeural +Gender: Female + +Name: ar-LY-OmarNeural +Gender: Male + +Name: ar-MA-JamalNeural +Gender: Male + +Name: ar-MA-MounaNeural +Gender: Female + +Name: ar-OM-AbdullahNeural +Gender: Male + +Name: ar-OM-AyshaNeural +Gender: Female + +Name: ar-QA-AmalNeural +Gender: Female + +Name: ar-QA-MoazNeural +Gender: Male + +Name: ar-SA-HamedNeural +Gender: Male + +Name: ar-SA-ZariyahNeural +Gender: Female + +Name: ar-SY-AmanyNeural +Gender: Female + +Name: ar-SY-LaithNeural +Gender: Male + +Name: ar-TN-HediNeural +Gender: Male + +Name: ar-TN-ReemNeural +Gender: Female + +Name: ar-YE-MaryamNeural +Gender: Female + +Name: ar-YE-SalehNeural +Gender: Male + +Name: az-AZ-BabekNeural +Gender: Male + +Name: az-AZ-BanuNeural +Gender: Female + +Name: bg-BG-BorislavNeural +Gender: Male + +Name: bg-BG-KalinaNeural +Gender: Female + +Name: bn-BD-NabanitaNeural +Gender: Female + +Name: bn-BD-PradeepNeural +Gender: Male + +Name: bn-IN-BashkarNeural +Gender: Male + +Name: bn-IN-TanishaaNeural +Gender: Female + +Name: bs-BA-GoranNeural +Gender: Male + +Name: bs-BA-VesnaNeural +Gender: Female + +Name: ca-ES-EnricNeural +Gender: Male + +Name: ca-ES-JoanaNeural +Gender: Female + +Name: cs-CZ-AntoninNeural +Gender: Male + +Name: cs-CZ-VlastaNeural +Gender: Female + +Name: cy-GB-AledNeural +Gender: Male + +Name: cy-GB-NiaNeural +Gender: Female + +Name: da-DK-ChristelNeural +Gender: Female + +Name: da-DK-JeppeNeural +Gender: Male + +Name: de-AT-IngridNeural +Gender: Female + +Name: de-AT-JonasNeural +Gender: Male + +Name: de-CH-JanNeural +Gender: Male + +Name: de-CH-LeniNeural +Gender: Female + +Name: de-DE-AmalaNeural +Gender: Female + +Name: de-DE-ConradNeural +Gender: Male + +Name: de-DE-FlorianMultilingualNeural +Gender: Male + +Name: de-DE-KatjaNeural +Gender: Female + +Name: de-DE-KillianNeural +Gender: Male + +Name: de-DE-SeraphinaMultilingualNeural +Gender: Female + +Name: el-GR-AthinaNeural +Gender: Female + +Name: el-GR-NestorasNeural +Gender: Male + +Name: en-AU-NatashaNeural +Gender: Female + +Name: en-AU-WilliamNeural +Gender: Male + +Name: en-CA-ClaraNeural +Gender: Female + +Name: en-CA-LiamNeural +Gender: Male + +Name: en-GB-LibbyNeural +Gender: Female + +Name: en-GB-MaisieNeural +Gender: Female + +Name: en-GB-RyanNeural +Gender: Male + +Name: en-GB-SoniaNeural +Gender: Female + +Name: en-GB-ThomasNeural +Gender: Male + +Name: en-HK-SamNeural +Gender: Male + +Name: en-HK-YanNeural +Gender: Female + +Name: en-IE-ConnorNeural +Gender: Male + +Name: en-IE-EmilyNeural +Gender: Female + +Name: en-IN-NeerjaExpressiveNeural +Gender: Female + +Name: en-IN-NeerjaNeural +Gender: Female + +Name: en-IN-PrabhatNeural +Gender: Male + +Name: en-KE-AsiliaNeural +Gender: Female + +Name: en-KE-ChilembaNeural +Gender: Male + +Name: en-NG-AbeoNeural +Gender: Male + +Name: en-NG-EzinneNeural +Gender: Female + +Name: en-NZ-MitchellNeural +Gender: Male + +Name: en-NZ-MollyNeural +Gender: Female + +Name: en-PH-JamesNeural +Gender: Male + +Name: en-PH-RosaNeural +Gender: Female + +Name: en-SG-LunaNeural +Gender: Female + +Name: en-SG-WayneNeural +Gender: Male + +Name: en-TZ-ElimuNeural +Gender: Male + +Name: en-TZ-ImaniNeural +Gender: Female + +Name: en-US-AnaNeural +Gender: Female + +Name: en-US-AndrewNeural +Gender: Male + +Name: en-US-AriaNeural +Gender: Female + +Name: en-US-AvaNeural +Gender: Female + +Name: en-US-BrianNeural +Gender: Male + +Name: en-US-ChristopherNeural +Gender: Male + +Name: en-US-EmmaNeural +Gender: Female + +Name: en-US-EricNeural +Gender: Male + +Name: en-US-GuyNeural +Gender: Male + +Name: en-US-JennyNeural +Gender: Female + +Name: en-US-MichelleNeural +Gender: Female + +Name: en-US-RogerNeural +Gender: Male + +Name: en-US-SteffanNeural +Gender: Male + +Name: en-ZA-LeahNeural +Gender: Female + +Name: en-ZA-LukeNeural +Gender: Male + +Name: es-AR-ElenaNeural +Gender: Female + +Name: es-AR-TomasNeural +Gender: Male + +Name: es-BO-MarceloNeural +Gender: Male + +Name: es-BO-SofiaNeural +Gender: Female + +Name: es-CL-CatalinaNeural +Gender: Female + +Name: es-CL-LorenzoNeural +Gender: Male + +Name: es-CO-GonzaloNeural +Gender: Male + +Name: es-CO-SalomeNeural +Gender: Female + +Name: es-CR-JuanNeural +Gender: Male + +Name: es-CR-MariaNeural +Gender: Female + +Name: es-CU-BelkysNeural +Gender: Female + +Name: es-CU-ManuelNeural +Gender: Male + +Name: es-DO-EmilioNeural +Gender: Male + +Name: es-DO-RamonaNeural +Gender: Female + +Name: es-EC-AndreaNeural +Gender: Female + +Name: es-EC-LuisNeural +Gender: Male + +Name: es-ES-AlvaroNeural +Gender: Male + +Name: es-ES-ElviraNeural +Gender: Female + +Name: es-ES-XimenaNeural +Gender: Female + +Name: es-GQ-JavierNeural +Gender: Male + +Name: es-GQ-TeresaNeural +Gender: Female + +Name: es-GT-AndresNeural +Gender: Male + +Name: es-GT-MartaNeural +Gender: Female + +Name: es-HN-CarlosNeural +Gender: Male + +Name: es-HN-KarlaNeural +Gender: Female + +Name: es-MX-DaliaNeural +Gender: Female + +Name: es-MX-JorgeNeural +Gender: Male + +Name: es-NI-FedericoNeural +Gender: Male + +Name: es-NI-YolandaNeural +Gender: Female + +Name: es-PA-MargaritaNeural +Gender: Female + +Name: es-PA-RobertoNeural +Gender: Male + +Name: es-PE-AlexNeural +Gender: Male + +Name: es-PE-CamilaNeural +Gender: Female + +Name: es-PR-KarinaNeural +Gender: Female + +Name: es-PR-VictorNeural +Gender: Male + +Name: es-PY-MarioNeural +Gender: Male + +Name: es-PY-TaniaNeural +Gender: Female + +Name: es-SV-LorenaNeural +Gender: Female + +Name: es-SV-RodrigoNeural +Gender: Male + +Name: es-US-AlonsoNeural +Gender: Male + +Name: es-US-PalomaNeural +Gender: Female + +Name: es-UY-MateoNeural +Gender: Male + +Name: es-UY-ValentinaNeural +Gender: Female + +Name: es-VE-PaolaNeural +Gender: Female + +Name: es-VE-SebastianNeural +Gender: Male + +Name: et-EE-AnuNeural +Gender: Female + +Name: et-EE-KertNeural +Gender: Male + +Name: fa-IR-DilaraNeural +Gender: Female + +Name: fa-IR-FaridNeural +Gender: Male + +Name: fi-FI-HarriNeural +Gender: Male + +Name: fi-FI-NooraNeural +Gender: Female + +Name: fil-PH-AngeloNeural +Gender: Male + +Name: fil-PH-BlessicaNeural +Gender: Female + +Name: fr-BE-CharlineNeural +Gender: Female + +Name: fr-BE-GerardNeural +Gender: Male + +Name: fr-CA-AntoineNeural +Gender: Male + +Name: fr-CA-JeanNeural +Gender: Male + +Name: fr-CA-SylvieNeural +Gender: Female + +Name: fr-CA-ThierryNeural +Gender: Male + +Name: fr-CH-ArianeNeural +Gender: Female + +Name: fr-CH-FabriceNeural +Gender: Male + +Name: fr-FR-DeniseNeural +Gender: Female + +Name: fr-FR-EloiseNeural +Gender: Female + +Name: fr-FR-HenriNeural +Gender: Male + +Name: fr-FR-RemyMultilingualNeural +Gender: Male + +Name: fr-FR-VivienneMultilingualNeural +Gender: Female + +Name: ga-IE-ColmNeural +Gender: Male + +Name: ga-IE-OrlaNeural +Gender: Female + +Name: gl-ES-RoiNeural +Gender: Male + +Name: gl-ES-SabelaNeural +Gender: Female + +Name: gu-IN-DhwaniNeural +Gender: Female + +Name: gu-IN-NiranjanNeural +Gender: Male + +Name: he-IL-AvriNeural +Gender: Male + +Name: he-IL-HilaNeural +Gender: Female + +Name: hi-IN-MadhurNeural +Gender: Male + +Name: hi-IN-SwaraNeural +Gender: Female + +Name: hr-HR-GabrijelaNeural +Gender: Female + +Name: hr-HR-SreckoNeural +Gender: Male + +Name: hu-HU-NoemiNeural +Gender: Female + +Name: hu-HU-TamasNeural +Gender: Male + +Name: id-ID-ArdiNeural +Gender: Male + +Name: id-ID-GadisNeural +Gender: Female + +Name: is-IS-GudrunNeural +Gender: Female + +Name: is-IS-GunnarNeural +Gender: Male + +Name: it-IT-DiegoNeural +Gender: Male + +Name: it-IT-ElsaNeural +Gender: Female + +Name: it-IT-GiuseppeNeural +Gender: Male + +Name: it-IT-IsabellaNeural +Gender: Female + +Name: ja-JP-KeitaNeural +Gender: Male + +Name: ja-JP-NanamiNeural +Gender: Female + +Name: jv-ID-DimasNeural +Gender: Male + +Name: jv-ID-SitiNeural +Gender: Female + +Name: ka-GE-EkaNeural +Gender: Female + +Name: ka-GE-GiorgiNeural +Gender: Male + +Name: kk-KZ-AigulNeural +Gender: Female + +Name: kk-KZ-DauletNeural +Gender: Male + +Name: km-KH-PisethNeural +Gender: Male + +Name: km-KH-SreymomNeural +Gender: Female + +Name: kn-IN-GaganNeural +Gender: Male + +Name: kn-IN-SapnaNeural +Gender: Female + +Name: ko-KR-HyunsuNeural +Gender: Male + +Name: ko-KR-InJoonNeural +Gender: Male + +Name: ko-KR-SunHiNeural +Gender: Female + +Name: lo-LA-ChanthavongNeural +Gender: Male + +Name: lo-LA-KeomanyNeural +Gender: Female + +Name: lt-LT-LeonasNeural +Gender: Male + +Name: lt-LT-OnaNeural +Gender: Female + +Name: lv-LV-EveritaNeural +Gender: Female + +Name: lv-LV-NilsNeural +Gender: Male + +Name: mk-MK-AleksandarNeural +Gender: Male + +Name: mk-MK-MarijaNeural +Gender: Female + +Name: ml-IN-MidhunNeural +Gender: Male + +Name: ml-IN-SobhanaNeural +Gender: Female + +Name: mn-MN-BataaNeural +Gender: Male + +Name: mn-MN-YesuiNeural +Gender: Female + +Name: mr-IN-AarohiNeural +Gender: Female + +Name: mr-IN-ManoharNeural +Gender: Male + +Name: ms-MY-OsmanNeural +Gender: Male + +Name: ms-MY-YasminNeural +Gender: Female + +Name: mt-MT-GraceNeural +Gender: Female + +Name: mt-MT-JosephNeural +Gender: Male + +Name: my-MM-NilarNeural +Gender: Female + +Name: my-MM-ThihaNeural +Gender: Male + +Name: nb-NO-FinnNeural +Gender: Male + +Name: nb-NO-PernilleNeural +Gender: Female + +Name: ne-NP-HemkalaNeural +Gender: Female + +Name: ne-NP-SagarNeural +Gender: Male + +Name: nl-BE-ArnaudNeural +Gender: Male + +Name: nl-BE-DenaNeural +Gender: Female + +Name: nl-NL-ColetteNeural +Gender: Female + +Name: nl-NL-FennaNeural +Gender: Female + +Name: nl-NL-MaartenNeural +Gender: Male + +Name: pl-PL-MarekNeural +Gender: Male + +Name: pl-PL-ZofiaNeural +Gender: Female + +Name: ps-AF-GulNawazNeural +Gender: Male + +Name: ps-AF-LatifaNeural +Gender: Female + +Name: pt-BR-AntonioNeural +Gender: Male + +Name: pt-BR-FranciscaNeural +Gender: Female + +Name: pt-BR-ThalitaNeural +Gender: Female + +Name: pt-PT-DuarteNeural +Gender: Male + +Name: pt-PT-RaquelNeural +Gender: Female + +Name: ro-RO-AlinaNeural +Gender: Female + +Name: ro-RO-EmilNeural +Gender: Male + +Name: ru-RU-DmitryNeural +Gender: Male + +Name: ru-RU-SvetlanaNeural +Gender: Female + +Name: si-LK-SameeraNeural +Gender: Male + +Name: si-LK-ThiliniNeural +Gender: Female + +Name: sk-SK-LukasNeural +Gender: Male + +Name: sk-SK-ViktoriaNeural +Gender: Female + +Name: sl-SI-PetraNeural +Gender: Female + +Name: sl-SI-RokNeural +Gender: Male + +Name: so-SO-MuuseNeural +Gender: Male + +Name: so-SO-UbaxNeural +Gender: Female + +Name: sq-AL-AnilaNeural +Gender: Female + +Name: sq-AL-IlirNeural +Gender: Male + +Name: sr-RS-NicholasNeural +Gender: Male + +Name: sr-RS-SophieNeural +Gender: Female + +Name: su-ID-JajangNeural +Gender: Male + +Name: su-ID-TutiNeural +Gender: Female + +Name: sv-SE-MattiasNeural +Gender: Male + +Name: sv-SE-SofieNeural +Gender: Female + +Name: sw-KE-RafikiNeural +Gender: Male + +Name: sw-KE-ZuriNeural +Gender: Female + +Name: sw-TZ-DaudiNeural +Gender: Male + +Name: sw-TZ-RehemaNeural +Gender: Female + +Name: ta-IN-PallaviNeural +Gender: Female + +Name: ta-IN-ValluvarNeural +Gender: Male + +Name: ta-LK-KumarNeural +Gender: Male + +Name: ta-LK-SaranyaNeural +Gender: Female + +Name: ta-MY-KaniNeural +Gender: Female + +Name: ta-MY-SuryaNeural +Gender: Male + +Name: ta-SG-AnbuNeural +Gender: Male + +Name: ta-SG-VenbaNeural +Gender: Female + +Name: te-IN-MohanNeural +Gender: Male + +Name: te-IN-ShrutiNeural +Gender: Female + +Name: th-TH-NiwatNeural +Gender: Male + +Name: th-TH-PremwadeeNeural +Gender: Female + +Name: tr-TR-AhmetNeural +Gender: Male + +Name: tr-TR-EmelNeural +Gender: Female + +Name: uk-UA-OstapNeural +Gender: Male + +Name: uk-UA-PolinaNeural +Gender: Female + +Name: ur-IN-GulNeural +Gender: Female + +Name: ur-IN-SalmanNeural +Gender: Male + +Name: ur-PK-AsadNeural +Gender: Male + +Name: ur-PK-UzmaNeural +Gender: Female + +Name: uz-UZ-MadinaNeural +Gender: Female + +Name: uz-UZ-SardorNeural +Gender: Male + +Name: vi-VN-HoaiMyNeural +Gender: Female + +Name: vi-VN-NamMinhNeural +Gender: Male + +Name: zh-CN-XiaoxiaoNeural +Gender: Female + +Name: zh-CN-XiaoyiNeural +Gender: Female + +Name: zh-CN-YunjianNeural +Gender: Male + +Name: zh-CN-YunxiNeural +Gender: Male + +Name: zh-CN-YunxiaNeural +Gender: Male + +Name: zh-CN-YunyangNeural +Gender: Male + +Name: zh-CN-liaoning-XiaobeiNeural +Gender: Female + +Name: zh-CN-shaanxi-XiaoniNeural +Gender: Female + +Name: zh-HK-HiuGaaiNeural +Gender: Female + +Name: zh-HK-HiuMaanNeural +Gender: Female + +Name: zh-HK-WanLungNeural +Gender: Male + +Name: zh-TW-HsiaoChenNeural +Gender: Female + +Name: zh-TW-HsiaoYuNeural +Gender: Female + +Name: zh-TW-YunJheNeural +Gender: Male + +Name: zu-ZA-ThandoNeural +Gender: Female + +Name: zu-ZA-ThembaNeural +Gender: Male diff --git a/docs/webui-en.jpg b/docs/webui-en.jpg new file mode 100644 index 0000000..37c1c91 Binary files /dev/null and b/docs/webui-en.jpg differ diff --git a/docs/webui.jpg b/docs/webui.jpg new file mode 100644 index 0000000..ce2387d Binary files /dev/null and b/docs/webui.jpg differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..e84f32b --- /dev/null +++ b/main.py @@ -0,0 +1,16 @@ +import uvicorn +from loguru import logger + +from app.config import config + +if __name__ == "__main__": + logger.info( + "start server, docs: http://127.0.0.1:" + str(config.listen_port) + "/docs" + ) + uvicorn.run( + app="app.asgi:app", + host=config.listen_host, + port=config.listen_port, + reload=config.reload_debug, + log_level="warning", + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..727eecc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +# 说明: +# 1. 主依赖来源统一收敛到 pyproject.toml。 +# 2. 运行环境通过 uv.lock 固定,避免不同机器解析出不同版本组合。 +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "moneyprinterturbo" +version = "1.2.9" +description = "Generate short videos from prompts, local assets, subtitles, and TTS." +readme = "README-en.md" +requires-python = ">=3.11,<3.13" +license = { text = "MIT" } +dependencies = [ + "moviepy==2.1.2", + "streamlit==1.45.0", + "edge-tts==7.2.7", + "fastapi==0.115.6", + "uvicorn==0.32.1", + "openai==1.56.1", + "faster-whisper==1.1.0", + "loguru==0.7.3", + "google-generativeai==0.8.6", + "dashscope==1.20.14", + "azure-cognitiveservices-speech==1.41.1", + "redis==5.2.0", + "python-multipart==0.0.19", + "pyyaml==6.0.3", + "requests==2.33.1", + "socksio==1.0.0", + "pydub==0.25.1", + "litellm==1.60.0", +] + +[project.optional-dependencies] +g4f = [ + "g4f==0.5.2.2", +] + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.uv] +# 当前项目以应用运行形态为主,不作为可发布的 Python 包安装。 +package = false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0c8a66a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +# Keep legacy pip install support; primary deps moved to pyproject.toml / uv.lock. +moviepy==2.1.2 +streamlit==1.45.0 +edge_tts==7.2.7 +fastapi==0.115.6 +uvicorn==0.32.1 +openai==1.56.1 +faster-whisper==1.1.0 +loguru==0.7.3 +google.generativeai==0.8.6 +dashscope==1.20.14 +g4f==0.5.2.2 +azure-cognitiveservices-speech==1.41.1 +redis==5.2.0 +python-multipart==0.0.19 +pyyaml==6.0.3 +requests==2.33.1 +socksio==1.0.0 +pydub==0.25.1 +litellm==1.60.0 diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..1deb3a9 --- /dev/null +++ b/test/README.md @@ -0,0 +1,40 @@ +# MoneyPrinterTurbo Test Directory + +This directory contains unit tests for the **MoneyPrinterTurbo** project. + +## Directory Structure + +- `services/`: Tests for components in the `app/services` directory + - `test_video.py`: Tests for the video service + - `test_task.py`: Tests for the task service + - `test_voice.py`: Tests for the voice service + +## Running Tests + +You can run the tests using Python’s built-in `unittest` framework: + +```bash +# Run all tests +python -m unittest discover -s test + +# Run a specific test file +python -m unittest test/services/test_video.py + +# Run a specific test class +python -m unittest test.services.test_video.TestVideoService + +# Run a specific test method +python -m unittest test.services.test_video.TestVideoService.test_preprocess_video +```` + +## Adding New Tests + +To add tests for other components, follow these guidelines: + +1. Create test files prefixed with `test_` in the appropriate subdirectory +2. Use `unittest.TestCase` as the base class for your test classes +3. Name test methods with the `test_` prefix + +## Test Resources + +Place any resource files required for testing in the `test/resources` directory. \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..584e3c9 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ +# Unit test package for test diff --git a/test/resources/1.png b/test/resources/1.png new file mode 100644 index 0000000..8c62fb6 Binary files /dev/null and b/test/resources/1.png differ diff --git a/test/resources/1.png.mp4 b/test/resources/1.png.mp4 new file mode 100644 index 0000000..05e351b Binary files /dev/null and b/test/resources/1.png.mp4 differ diff --git a/test/resources/2.png b/test/resources/2.png new file mode 100644 index 0000000..2d11adc Binary files /dev/null and b/test/resources/2.png differ diff --git a/test/resources/2.png.mp4 b/test/resources/2.png.mp4 new file mode 100644 index 0000000..6e622c7 Binary files /dev/null and b/test/resources/2.png.mp4 differ diff --git a/test/resources/3.png b/test/resources/3.png new file mode 100644 index 0000000..924dcee Binary files /dev/null and b/test/resources/3.png differ diff --git a/test/resources/3.png.mp4 b/test/resources/3.png.mp4 new file mode 100644 index 0000000..9f06ed3 Binary files /dev/null and b/test/resources/3.png.mp4 differ diff --git a/test/resources/4.png b/test/resources/4.png new file mode 100644 index 0000000..9d6516d Binary files /dev/null and b/test/resources/4.png differ diff --git a/test/resources/5.png b/test/resources/5.png new file mode 100644 index 0000000..fdd5925 Binary files /dev/null and b/test/resources/5.png differ diff --git a/test/resources/6.png b/test/resources/6.png new file mode 100644 index 0000000..94c8987 Binary files /dev/null and b/test/resources/6.png differ diff --git a/test/resources/7.png b/test/resources/7.png new file mode 100644 index 0000000..54bd1ce Binary files /dev/null and b/test/resources/7.png differ diff --git a/test/resources/8.png b/test/resources/8.png new file mode 100644 index 0000000..a9cf7ed Binary files /dev/null and b/test/resources/8.png differ diff --git a/test/resources/9.png b/test/resources/9.png new file mode 100644 index 0000000..c39d181 Binary files /dev/null and b/test/resources/9.png differ diff --git a/test/services/__init__.py b/test/services/__init__.py new file mode 100644 index 0000000..a07aa62 --- /dev/null +++ b/test/services/__init__.py @@ -0,0 +1 @@ +# Unit test package for services \ No newline at end of file diff --git a/test/services/test_llm.py b/test/services/test_llm.py new file mode 100644 index 0000000..edf21a8 --- /dev/null +++ b/test/services/test_llm.py @@ -0,0 +1,505 @@ +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +from pydantic import ValidationError + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from app.config import config +from app.models.schema import VideoScriptRequest +from app.services import llm + + +class TestScriptPromptOptions(unittest.TestCase): + def test_build_script_prompt_appends_advanced_requirements(self): + """ + 高级文案要求只作为附加约束,不替换默认系统提示词。 + 这样普通用户不配置时仍然走稳定默认规则,高级用户也能细化风格。 + """ + prompt = llm.build_script_prompt( + video_subject="咖啡", + language="zh-CN", + paragraph_number=3, + video_script_prompt="语气轻松,面向程序员", + ) + + self.assertIn("# Role: Video Script Generator", prompt) + self.assertIn("- video subject: 咖啡", prompt) + self.assertIn("- number of paragraphs: 3", prompt) + self.assertIn("- language: zh-CN", prompt) + self.assertIn("# Additional User Requirements:", prompt) + self.assertIn("语气轻松,面向程序员", prompt) + + def test_custom_system_prompt_keeps_runtime_context(self): + """ + 自定义 system prompt 会替换默认脚本规则,但视频主题、语言、段落数 + 仍由服务层统一追加,避免高级用户漏写必要上下文。 + """ + prompt = llm.build_script_prompt( + video_subject="露营", + language="en", + paragraph_number=2, + custom_system_prompt="Only write cinematic narration.", + ) + + self.assertNotIn("# Role: Video Script Generator", prompt) + self.assertIn("Only write cinematic narration.", prompt) + self.assertIn("- video subject: 露营", prompt) + self.assertIn("- number of paragraphs: 2", prompt) + self.assertIn("- language: en", prompt) + + def test_generate_script_sends_custom_prompt_to_llm(self): + captured = {} + + def fake_generate_response(prompt): + captured["prompt"] = prompt + return "第一段。\n\n第二段。" + + with patch.object(llm, "_generate_response", side_effect=fake_generate_response): + result = llm.generate_script( + video_subject="咖啡", + language="zh-CN", + paragraph_number=2, + video_script_prompt="开头更有悬念", + ) + + self.assertEqual(result, "第一段。\n\n第二段。") + self.assertIn("- number of paragraphs: 2", captured["prompt"]) + self.assertIn("开头更有悬念", captured["prompt"]) + + def test_video_script_request_rejects_invalid_advanced_options(self): + """ + API 请求模型需要限制高级 prompt 参数,避免外部调用绕过 WebUI + 传入异常段落数或超长提示词,导致模型成本和结果不可控。 + """ + with self.assertRaises(ValidationError): + VideoScriptRequest(video_subject="咖啡", paragraph_number=0) + + with self.assertRaises(ValidationError): + VideoScriptRequest( + video_subject="咖啡", + video_script_prompt="x" * (llm.MAX_SCRIPT_PROMPT_LENGTH + 1), + ) + + +class TestLiteLLMProvider(unittest.TestCase): + def setUp(self): + self.original_app_config = dict(config.app) + + def tearDown(self): + config.app.clear() + config.app.update(self.original_app_config) + + def _use_litellm_provider(self, model_name="openai/gpt-4o-mini"): + config.app["llm_provider"] = "litellm" + config.app["litellm_model_name"] = model_name + + def test_litellm_provider_returns_normalized_text(self): + """ + 验证 LiteLLM provider 的主路径不依赖真实网络和私有 API key。 + + 这里用 fake module 注入 `sys.modules`,直接覆盖动态 import 的 + `litellm.completion()`,确保测试稳定覆盖 `_generate_response()` 里的 + litellm 分支。 + """ + self._use_litellm_provider() + + fake_litellm = types.SimpleNamespace() + + def _completion(**kwargs): + self.assertEqual(kwargs["model"], "openai/gpt-4o-mini") + self.assertEqual( + kwargs["messages"], [{"role": "user", "content": "Say hello"}] + ) + self.assertTrue(kwargs["drop_params"]) + message = types.SimpleNamespace(content="hello\nworld") + choice = types.SimpleNamespace(message=message) + return types.SimpleNamespace(choices=[choice]) + + fake_litellm.completion = _completion + + with patch.dict(sys.modules, {"litellm": fake_litellm}): + result = llm._generate_response("Say hello") + + self.assertEqual(result, "helloworld") + + def test_litellm_provider_requires_model_name(self): + self._use_litellm_provider(model_name="") + + result = llm._generate_response("test") + + self.assertIn("Error:", result) + self.assertIn("model_name is not set", result) + + def test_litellm_provider_handles_empty_response(self): + self._use_litellm_provider() + + fake_litellm = types.SimpleNamespace( + completion=lambda **kwargs: types.SimpleNamespace(choices=[]) + ) + + with patch.dict(sys.modules, {"litellm": fake_litellm}): + result = llm._generate_response("test") + + self.assertIn("Error:", result) + self.assertIn("returned empty response", result) + + def test_litellm_provider_handles_empty_message(self): + """ + 某些 OpenAI-compatible 网关在内容过滤或安全拦截时会返回 + HTTP 200,但 `choices[0].message` 为 None。这里必须返回 + 可诊断的错误,而不是抛出 AttributeError。 + """ + self._use_litellm_provider() + + fake_litellm = types.SimpleNamespace( + completion=lambda **kwargs: types.SimpleNamespace( + choices=[types.SimpleNamespace(message=None)] + ) + ) + + with patch.dict(sys.modules, {"litellm": fake_litellm}): + result = llm._generate_response("test") + + self.assertIn("Error:", result) + self.assertIn("returned empty message", result) + + def test_openai_provider_still_uses_existing_path(self): + config.app["llm_provider"] = "openai" + config.app["openai_api_key"] = "" + config.app["openai_base_url"] = "https://api.openai.com/v1" + config.app["openai_model_name"] = "gpt-4o-mini" + + result = llm._generate_response("test") + + self.assertIn("Error:", result) + self.assertIn("api_key is not set", result) + self.assertNotIn("litellm", result.lower()) + + def test_grok_provider_still_uses_existing_path(self): + config.app["llm_provider"] = "grok" + config.app["grok_api_key"] = "" + config.app["grok_base_url"] = "https://api.x.ai/v1" + config.app["grok_model_name"] = "grok-4.3" + + result = llm._generate_response("test") + + self.assertIn("Error:", result) + self.assertIn("api_key is not set", result) + self.assertNotIn("litellm", result.lower()) + + def _use_ollama_provider(self, base_url=""): + config.app["llm_provider"] = "ollama" + config.app["ollama_api_key"] = "" + config.app["ollama_base_url"] = base_url + config.app["ollama_model_name"] = "llama3" + + def _assert_ollama_base_url(self, expected_base_url: str): + class FakeCompletions: + def create(self, **kwargs): + self.kwargs = kwargs + message = types.SimpleNamespace(content="hello\nollama") + choice = types.SimpleNamespace(message=message) + return types.SimpleNamespace(choices=[choice]) + + fake_completions = FakeCompletions() + fake_client = types.SimpleNamespace( + chat=types.SimpleNamespace(completions=fake_completions) + ) + + with ( + patch.object(llm, "OpenAI", return_value=fake_client) as openai_client, + patch.object(llm, "ChatCompletion", types.SimpleNamespace), + ): + result = llm._generate_response("Say hello") + + openai_client.assert_called_once_with( + api_key="ollama", + base_url=expected_base_url, + ) + self.assertEqual( + fake_completions.kwargs, + { + "model": "llama3", + "messages": [{"role": "user", "content": "Say hello"}], + }, + ) + self.assertEqual(result, "helloollama") + + def test_ollama_default_base_url_uses_localhost_outside_container(self): + """ + 普通本机运行时,Ollama 默认仍然使用 localhost,避免影响已有用户。 + """ + self._use_ollama_provider() + + with patch.object(config, "is_running_in_container", return_value=False): + self._assert_ollama_base_url("http://localhost:11434/v1") + + def test_ollama_default_base_url_uses_host_gateway_inside_container(self): + """ + 容器内运行时,localhost 指向容器自身;默认改为 host.docker.internal, + 方便 Docker Desktop 用户访问宿主机上的 Ollama。 + """ + self._use_ollama_provider() + + with ( + patch.object(config, "is_running_in_container", return_value=True), + patch.object(config, "_can_resolve_hostname", return_value=True), + ): + self._assert_ollama_base_url("http://host.docker.internal:11434/v1") + + def test_ollama_default_base_url_falls_back_to_container_gateway(self): + """ + 原生 Linux Docker 里不一定能解析 host.docker.internal。此时使用容器 + 默认网关作为兜底地址,比直接返回不可解析的 hostname 更稳。 + """ + self._use_ollama_provider() + + with ( + patch.object(config, "is_running_in_container", return_value=True), + patch.object(config, "_can_resolve_hostname", return_value=False), + patch.object(config, "get_container_default_gateway_ip", return_value="172.17.0.1"), + ): + self._assert_ollama_base_url("http://172.17.0.1:11434/v1") + + def test_ollama_explicit_base_url_takes_precedence(self): + """ + 用户手动配置的 ollama_base_url 优先级最高,不受容器检测影响。 + """ + self._use_ollama_provider(base_url="http://ollama:11434/v1") + + with patch.object(config, "is_running_in_container", return_value=True): + self._assert_ollama_base_url("http://ollama:11434/v1") + + def test_mimo_provider_uses_openai_compatible_client(self): + """ + MiMo 官方接口兼容 OpenAI Chat Completions 协议。这里用 fake OpenAI + client 验证 provider 会使用 MiMo 独立配置和默认 base_url,不依赖 + 真实网络或私有 API Key。 + """ + config.app["llm_provider"] = "mimo" + config.app["mimo_api_key"] = "mimo-key" + config.app["mimo_base_url"] = "" + config.app["mimo_model_name"] = "" + + class FakeCompletions: + def create(self, **kwargs): + self.kwargs = kwargs + message = types.SimpleNamespace(content="hello\nmimo") + choice = types.SimpleNamespace(message=message) + return types.SimpleNamespace(choices=[choice]) + + fake_completions = FakeCompletions() + fake_client = types.SimpleNamespace( + chat=types.SimpleNamespace(completions=fake_completions) + ) + + with ( + patch.object(llm, "OpenAI", return_value=fake_client) as openai_client, + patch.object(llm, "ChatCompletion", types.SimpleNamespace), + ): + result = llm._generate_response("Say hello") + + openai_client.assert_called_once_with( + api_key="mimo-key", + base_url="https://api.xiaomimimo.com/v1", + ) + self.assertEqual( + fake_completions.kwargs, + { + "model": "mimo-v2.5-pro", + "messages": [{"role": "user", "content": "Say hello"}], + }, + ) + self.assertEqual(result, "hellomimo") + + def test_azure_provider_uses_azure_client_directly(self): + """ + Azure OpenAI 的鉴权、endpoint 和 api-version 都由 AzureOpenAI 客户端处理。 + 这个测试覆盖 issue #892:azure 分支必须直接调用 AzureOpenAI 创建的客户端, + 不能继续落入普通 OpenAI-compatible 分支,否则会丢失 Azure 专用请求配置。 + """ + config.app["llm_provider"] = "azure" + config.app["azure_api_key"] = "azure-key" + config.app["azure_base_url"] = "https://example.openai.azure.com" + config.app["azure_model_name"] = "gpt-4o-mini" + config.app["azure_api_version"] = "2024-02-15-preview" + + class FakeCompletions: + def create(self, **kwargs): + self.kwargs = kwargs + message = types.SimpleNamespace(content="hello\nazure") + choice = types.SimpleNamespace(message=message) + return types.SimpleNamespace(choices=[choice]) + + fake_completions = FakeCompletions() + fake_client = types.SimpleNamespace( + chat=types.SimpleNamespace(completions=fake_completions) + ) + + with ( + patch.object(llm, "AzureOpenAI", return_value=fake_client) as azure_client, + patch.object(llm, "OpenAI") as openai_client, + patch.object(llm, "ChatCompletion", types.SimpleNamespace), + ): + result = llm._generate_response("Say hello") + + azure_client.assert_called_once_with( + api_key="azure-key", + api_version="2024-02-15-preview", + azure_endpoint="https://example.openai.azure.com", + ) + openai_client.assert_not_called() + self.assertEqual( + fake_completions.kwargs, + { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Say hello"}], + }, + ) + self.assertEqual(result, "helloazure") + + def test_g4f_provider_requires_explicit_opt_in(self): + """ + g4f 存在供应链和稳定性风险,不能因为用户把 provider 写成 g4f + 就默认加载第三方包并访问逆向接口,必须显式启用。 + """ + config.app["llm_provider"] = "g4f" + config.app["enable_g4f"] = False + + result = llm._generate_response("test") + + self.assertIn("Error:", result) + self.assertIn("g4f provider is disabled", result) + + def test_g4f_provider_uses_lazy_import_after_opt_in(self): + config.app["llm_provider"] = "g4f" + config.app["enable_g4f"] = True + config.app["g4f_model_name"] = "gpt-3.5-turbo" + + fake_g4f = types.SimpleNamespace() + fake_g4f.ChatCompletion = types.SimpleNamespace( + create=lambda **kwargs: "hello from g4f" + ) + + with patch.dict(sys.modules, {"g4f": fake_g4f}): + result = llm._generate_response("test") + + self.assertEqual(result, "hello from g4f") + + def test_g4f_provider_reports_missing_optional_dependency(self): + config.app["llm_provider"] = "g4f" + config.app["enable_g4f"] = True + config.app["g4f_model_name"] = "gpt-3.5-turbo" + + with patch.dict(sys.modules, {"g4f": None}): + result = llm._generate_response("test") + + self.assertIn("Error:", result) + self.assertIn("g4f package is not installed by default", result) + + +class TestRuntimeEnvironmentDetection(unittest.TestCase): + def test_container_detection_ignores_plain_linux_cgroup_file(self): + """ + 普通 Linux 也有 /proc/1/cgroup,不能因为文件存在就判定为容器。 + """ + with tempfile.TemporaryDirectory() as tmp_dir: + cgroup_path = Path(tmp_dir) / "cgroup" + cgroup_path.write_text("0::/init.scope\n", encoding="utf-8") + + self.assertFalse( + config.is_running_in_container( + dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"), + containerenv_path=str(Path(tmp_dir) / "missing-containerenv"), + cgroup_path=str(cgroup_path), + ) + ) + + def test_container_detection_accepts_dockerenv_marker(self): + with tempfile.TemporaryDirectory() as tmp_dir: + dockerenv_path = Path(tmp_dir) / ".dockerenv" + dockerenv_path.write_text("", encoding="utf-8") + + self.assertTrue( + config.is_running_in_container( + dockerenv_path=str(dockerenv_path), + containerenv_path=str(Path(tmp_dir) / "missing-containerenv"), + cgroup_path=str(Path(tmp_dir) / "missing-cgroup"), + ) + ) + + def test_container_detection_accepts_cgroup_container_marker(self): + with tempfile.TemporaryDirectory() as tmp_dir: + cgroup_path = Path(tmp_dir) / "cgroup" + cgroup_path.write_text( + "0::/system.slice/docker-abcdef.scope\n", + encoding="utf-8", + ) + + self.assertTrue( + config.is_running_in_container( + dockerenv_path=str(Path(tmp_dir) / "missing-dockerenv"), + containerenv_path=str(Path(tmp_dir) / "missing-containerenv"), + cgroup_path=str(cgroup_path), + ) + ) + + def test_container_gateway_ip_decodes_default_route(self): + with tempfile.TemporaryDirectory() as tmp_dir: + route_path = Path(tmp_dir) / "route" + route_path.write_text( + "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" + "eth0\t00000000\t010011AC\t0003\t0\t0\t0\t00000000\t0\t0\t0\n", + encoding="utf-8", + ) + + self.assertEqual( + config.get_container_default_gateway_ip(str(route_path)), + "172.17.0.1", + ) + + def test_container_gateway_ip_ignores_missing_default_route(self): + with tempfile.TemporaryDirectory() as tmp_dir: + route_path = Path(tmp_dir) / "route" + route_path.write_text( + "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" + "eth0\t0011AC0A\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n", + encoding="utf-8", + ) + + self.assertEqual(config.get_container_default_gateway_ip(str(route_path)), "") + + +FOUNDRY_KEY = os.environ.get("ANTHROPIC_FOUNDRY_API_KEY", "") +FOUNDRY_BASE = "https://amanrai-test-resource.services.ai.azure.com/anthropic" +FOUNDRY_MODEL = "azure_ai/claude-sonnet-4-6" + + +@unittest.skipUnless(FOUNDRY_KEY, "ANTHROPIC_FOUNDRY_API_KEY not set") +class TestLiteLLMLiveIntegration(unittest.TestCase): + def setUp(self): + self.original_app_config = dict(config.app) + config.app["llm_provider"] = "litellm" + config.app["litellm_model_name"] = FOUNDRY_MODEL + os.environ["AZURE_AI_API_KEY"] = FOUNDRY_KEY + os.environ["AZURE_AI_API_BASE"] = FOUNDRY_BASE + + def tearDown(self): + config.app.clear() + config.app.update(self.original_app_config) + + def test_live_litellm_completion(self): + result = llm._generate_response("What is 2+2? Reply with just the number.") + + self.assertNotIn("Error:", result) + self.assertIn("4", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/services/test_material.py b/test/services/test_material.py new file mode 100644 index 0000000..608a820 --- /dev/null +++ b/test/services/test_material.py @@ -0,0 +1,132 @@ +import os +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from app.config import config +from app.services import material + + +class TestMaterialTlsVerification(unittest.TestCase): + def setUp(self): + self.original_app_config = dict(config.app) + self.original_proxy_config = dict(config.proxy) + + def tearDown(self): + config.app.clear() + config.app.update(self.original_app_config) + config.proxy.clear() + config.proxy.update(self.original_proxy_config) + + def test_search_pexels_uses_tls_verification_by_default(self): + """ + 默认路径必须开启 TLS 校验,避免素材 API key 和返回的素材 URL + 在公共网络或不可信代理环境中被中间人攻击截获或篡改。 + """ + config.app["pexels_api_keys"] = ["pexels-key"] + config.app.pop("tls_verify", None) + config.proxy.clear() + + fake_response = SimpleNamespace( + json=lambda: { + "videos": [ + { + "duration": 8, + "video_files": [ + { + "width": 1080, + "height": 1920, + "link": "https://example.com/video.mp4", + } + ], + } + ] + } + ) + + with patch("app.services.material.requests.get", return_value=fake_response) as get: + results = material.search_videos_pexels("cat", minimum_duration=1) + + self.assertEqual(len(results), 1) + self.assertTrue(get.call_args.kwargs["verify"]) + + def test_search_pixabay_allows_explicit_tls_disable_for_proxy(self): + """ + 少数企业代理会使用自签证书。该场景必须显式配置关闭 TLS 校验, + 不能再由代码硬编码默认关闭。 + """ + config.app["pixabay_api_keys"] = ["pixabay-key"] + config.app["tls_verify"] = False + config.proxy.clear() + + fake_response = SimpleNamespace( + json=lambda: { + "hits": [ + { + "duration": 8, + "videos": { + "large": { + "width": 1920, + "url": "https://example.com/video.mp4", + } + }, + } + ] + } + ) + + with patch("app.services.material.requests.get", return_value=fake_response) as get: + results = material.search_videos_pixabay("cat", minimum_duration=1) + + self.assertEqual(len(results), 1) + self.assertFalse(get.call_args.kwargs["verify"]) + + def test_save_video_uses_tls_verification_by_default(self): + config.app.pop("tls_verify", None) + config.proxy.clear() + + fake_response = SimpleNamespace(content=b"fake-video") + + class FakeVideoFileClip: + duration = 1 + fps = 24 + + def __init__(self, path): + self.path = path + + def close(self): + return None + + with tempfile.TemporaryDirectory() as temp_dir: + with patch( + "app.services.material.requests.get", return_value=fake_response + ) as get, patch("app.services.material.VideoFileClip", FakeVideoFileClip): + video_path = material.save_video( + "https://example.com/video.mp4?token=abc", save_dir=temp_dir + ) + + self.assertTrue(os.path.exists(video_path)) + self.assertTrue(get.call_args.kwargs["verify"]) + + def test_download_videos_accepts_plain_string_concat_mode(self): + """ + download_videos 可能被服务层或测试直接传入字符串模式,而不是 + VideoConcatMode 枚举。这里用空搜索词避免真实网络请求,只验证 + 字符串 "random" 不会再因为访问 `.value` 抛 AttributeError。 + """ + result = material.download_videos( + task_id="string-concat-mode", + search_terms=[], + video_contact_mode="random", + ) + + self.assertEqual(result, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/services/test_state.py b/test/services/test_state.py new file mode 100644 index 0000000..dcb67ce --- /dev/null +++ b/test/services/test_state.py @@ -0,0 +1,74 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from app.services.state import RedisState + + +class _FakeRedis: + def __init__(self, batches): + self.batches = batches + self.data = {} + for key in [key for batch in batches for key in batch]: + index = int(key.decode("utf-8").split(":")[-1]) + self.data[key] = { + b"task_id": key, + b"state": b"1", + b"progress": str(index).encode("utf-8"), + } + + def scan(self, cursor, count): + batch_index = int(cursor) + next_cursor = batch_index + 1 + if next_cursor >= len(self.batches): + next_cursor = 0 + return next_cursor, self.batches[batch_index] + + def hgetall(self, key): + return self.data[key] + + +class TestRedisState(unittest.TestCase): + def _build_state(self, batch_sizes): + keys = [f"task:{i}".encode("utf-8") for i in range(sum(batch_sizes))] + batches = [] + offset = 0 + for batch_size in batch_sizes: + batches.append(keys[offset : offset + batch_size]) + offset += batch_size + + state = RedisState.__new__(RedisState) + state._redis = _FakeRedis(batches) + return state + + def test_get_all_tasks_paginates_across_scan_batches(self): + """ + Redis SCAN 分批返回 key 时,分页切片必须按当前批次起始位置计算。 + + 这个用例复现 PR #890 描述的 18 条任务、page_size=10 场景: + 第一批 10 条,第二批 8 条。旧逻辑第一页会返回空列表,第二页 + 只返回 2 条;修复后第一页返回 10 条,第二页返回剩余 8 条。 + """ + state = self._build_state([10, 8]) + + first_page, first_total = state.get_all_tasks(page=1, page_size=10) + second_page, second_total = state.get_all_tasks(page=2, page_size=10) + + self.assertEqual(first_total, 18) + self.assertEqual(second_total, 18) + self.assertEqual(len(first_page), 10) + self.assertEqual(len(second_page), 8) + self.assertEqual( + [task["task_id"] for task in first_page], + [f"task:{i}" for i in range(10)], + ) + self.assertEqual( + [task["task_id"] for task in second_page], + [f"task:{i}" for i in range(10, 18)], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/services/test_task.py b/test/services/test_task.py new file mode 100644 index 0000000..a645fac --- /dev/null +++ b/test/services/test_task.py @@ -0,0 +1,93 @@ +import unittest +import os +import sys +from pathlib import Path +from unittest.mock import patch + +# add project root to python path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from app.services import task as tm +from app.models.schema import MaterialInfo, VideoParams + +resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources") + +class TestTaskService(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_generate_script_forwards_advanced_prompt_options(self): + """ + 任务生成入口和 WebUI/API 共用 VideoParams。这里验证自动生成文案时, + 高级提示词参数会继续传到 LLM 服务层,避免只在 /scripts 接口生效。 + """ + params = VideoParams( + video_subject="咖啡", + video_script="", + video_language="zh-CN", + paragraph_number=2, + video_script_prompt="语气轻松", + custom_system_prompt="Only write short narration.", + ) + + with patch.object(tm.llm, "generate_script", return_value="生成的文案") as generate: + result = tm.generate_script("task-id", params) + + self.assertEqual(result, "生成的文案") + generate.assert_called_once_with( + video_subject="咖啡", + language="zh-CN", + paragraph_number=2, + video_script_prompt="语气轻松", + custom_system_prompt="Only write short narration.", + ) + + def test_task_local_materials(self): + task_id = "00000000-0000-0000-0000-000000000000" + video_materials=[] + for i in range(1, 4): + video_materials.append(MaterialInfo( + provider="local", + url=os.path.join(resources_dir, f"{i}.png"), + duration=0 + )) + + params = VideoParams( + video_subject="金钱的作用", + video_script="金钱不仅是交换媒介,更是社会资源的分配工具。它能满足基本生存需求,如食物和住房,也能提供教育、医疗等提升生活品质的机会。拥有足够的金钱意味着更多选择权,比如职业自由或创业可能。但金钱的作用也有边界,它无法直接购买幸福、健康或真诚的人际关系。过度追逐财富可能导致价值观扭曲,忽视精神层面的需求。理想的状态是理性看待金钱,将其作为实现目标的工具而非终极目的。", + video_terms="money importance, wealth and society, financial freedom, money and happiness, role of money", + video_aspect="9:16", + video_concat_mode="random", + video_transition_mode="None", + video_clip_duration=3, + video_count=1, + video_source="local", + video_materials=video_materials, + video_language="", + voice_name="zh-CN-XiaoxiaoNeural-Female", + voice_volume=1.0, + voice_rate=1.0, + bgm_type="random", + bgm_file="", + bgm_volume=0.2, + subtitle_enabled=True, + subtitle_position="bottom", + custom_position=70.0, + font_name="MicrosoftYaHeiBold.ttc", + text_fore_color="#FFFFFF", + text_background_color=True, + font_size=60, + stroke_color="#000000", + stroke_width=1.5, + n_threads=2, + paragraph_number=1 + ) + result = tm.start(task_id=task_id, params=params) + print(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/services/test_video.py b/test/services/test_video.py new file mode 100644 index 0000000..3da5c2a --- /dev/null +++ b/test/services/test_video.py @@ -0,0 +1,315 @@ + +import unittest +import os +import shutil +import sys +import tempfile +import types +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +from unittest.mock import patch +from moviepy import ( + VideoFileClip, +) +# add project root to python path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +from app.config import config +from app.controllers.manager.base_manager import TaskQueueFullError +from app.controllers.manager.memory_manager import InMemoryTaskManager +from app.controllers.v1 import video as video_controller +from app.models import const +from app.models.schema import MaterialInfo +from app.services import state as sm +from app.services import video as vd +from app.utils import utils + +resources_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "resources") + + +class _FakeRequest: + def __init__(self): + self.headers = {"x-task-id": "test-request"} + + +class TestSecurityControls(unittest.TestCase): + def setUp(self): + self.original_app_config = dict(config.app) + + def tearDown(self): + config.app.clear() + config.app.update(self.original_app_config) + + def test_task_query_returns_relative_task_url_without_mutating_state(self): + """ + endpoint 未显式配置时,任务查询接口不能使用 Host 派生绝对 URL, + 也不能把展示 URL 回写到任务状态里,否则不同 Host 查询会污染结果。 + """ + task_id = "security-task-url" + task_dir = utils.task_dir(task_id) + video_path = os.path.join(task_dir, "final-1.mp4") + Path(video_path).write_bytes(b"fake-video") + config.app["endpoint"] = "" + + try: + sm.state.update_task( + task_id, + state=const.TASK_STATE_COMPLETE, + videos=[video_path], + combined_videos=[video_path], + ) + + response = video_controller.get_task(_FakeRequest(), task_id=task_id) + + self.assertEqual(response["data"]["videos"], [f"/tasks/{task_id}/final-1.mp4"]) + self.assertEqual(sm.state.get_task(task_id)["videos"], [video_path]) + finally: + sm.state.delete_task(task_id) + shutil.rmtree(task_dir, ignore_errors=True) + + def test_in_memory_task_manager_rejects_when_queue_is_full(self): + """ + 并发数用尽后,等待队列必须有硬上限。这里用 max_concurrent_tasks=0 + 强制任务进入队列,验证超过 max_queued_tasks 时会拒绝继续入队。 + """ + manager = InMemoryTaskManager(max_concurrent_tasks=0, max_queued_tasks=1) + + manager.add_task(lambda: None) + + with self.assertRaises(TaskQueueFullError): + manager.add_task(lambda: None) + +class TestVideoService(unittest.TestCase): + def setUp(self): + self.test_img_path = os.path.join(resources_dir, "1.png") + + def tearDown(self): + pass + + def test_preprocess_video(self): + if not os.path.exists(self.test_img_path): + self.fail(f"test image not found: {self.test_img_path}") + + local_videos_dir = utils.storage_dir("local_videos", create=True) + safe_img_path = os.path.join(local_videos_dir, "test-preprocess-1.png") + shutil.copy2(self.test_img_path, safe_img_path) + + # test preprocess_video function + m = MaterialInfo() + m.url = os.path.basename(safe_img_path) + m.provider = "local" + print(m) + + try: + materials = vd.preprocess_video([m], clip_duration=4) + print(materials) + + # verify result + self.assertIsNotNone(materials) + self.assertEqual(len(materials), 1) + self.assertTrue(materials[0].url.endswith(".mp4")) + + # moviepy get video info + clip = VideoFileClip(materials[0].url) + try: + print(clip) + finally: + clip.close() + + # clean generated test video file + if os.path.exists(materials[0].url): + os.remove(materials[0].url) + finally: + if os.path.exists(safe_img_path): + os.remove(safe_img_path) + + def test_preprocess_video_rejects_material_outside_local_videos(self): + """ + local 素材路径来自 API 参数,不能允许任意绝对路径进入 MoviePy。 + 这里验证非 local_videos 白名单目录内的路径会被跳过,避免任意文件读取。 + """ + m = MaterialInfo(provider="local", url=self.test_img_path) + + materials = vd.preprocess_video([m], clip_duration=4) + + self.assertEqual(materials, []) + + def test_get_bgm_file_accepts_song_directory_filename(self): + """ + BGM 列表接口现在只暴露文件名;生成视频时应能把文件名安全解析回 + resource/songs 白名单目录,保持正常使用路径可用。 + """ + song_dir = utils.song_dir() + bgm_path = os.path.join(song_dir, "test-safe-bgm.mp3") + Path(bgm_path).write_bytes(b"fake-mp3") + + try: + self.assertEqual(vd.get_bgm_file(bgm_file="test-safe-bgm.mp3"), bgm_path) + finally: + if os.path.exists(bgm_path): + os.remove(bgm_path) + + def test_get_bgm_file_accepts_project_relative_song_path(self): + """ + 用户在 WebUI 中可能直接填写 ./resource/songs/xxx.mp3。该路径虽然是 + 项目根目录相对路径,但实际文件仍在 resource/songs 白名单目录内, + 应该被接受,避免自定义背景音乐被误判为不存在。 + """ + song_dir = utils.song_dir() + bgm_path = os.path.join(song_dir, "test-relative-bgm.mp3") + Path(bgm_path).write_bytes(b"fake-mp3") + + try: + self.assertEqual( + vd.get_bgm_file(bgm_file="./resource/songs/test-relative-bgm.mp3"), + bgm_path, + ) + finally: + if os.path.exists(bgm_path): + os.remove(bgm_path) + + def test_get_bgm_file_rejects_path_outside_song_directory(self): + """ + 用户传入的 bgm_file 不能直接作为本地路径打开,否则可能读取系统文件。 + 即使外部文件存在,也必须因为不在 songs 目录内被拒绝。 + """ + with tempfile.NamedTemporaryFile(suffix=".mp3") as temp_bgm: + self.assertEqual(vd.get_bgm_file(bgm_file=temp_bgm.name), "") + + def test_get_ffmpeg_binary_uses_configured_env_path(self): + """配置中显式指定 ffmpeg 时,应优先使用该路径。""" + with patch.dict(os.environ, {"IMAGEIO_FFMPEG_EXE": "/tmp/custom-ffmpeg"}, clear=True): + self.assertEqual(vd.get_ffmpeg_binary(), "/tmp/custom-ffmpeg") + + def test_get_ffmpeg_binary_falls_back_to_imageio_ffmpeg(self): + """ + Windows 便携包里系统 PATH 可能没有 ffmpeg,但 moviepy 依赖的 + imageio-ffmpeg 通常会提供可执行文件。这里验证该兜底路径可用。 + """ + fake_imageio_ffmpeg = types.SimpleNamespace( + get_ffmpeg_exe=lambda: "/tmp/bundled-ffmpeg" + ) + + with patch.dict(os.environ, {}, clear=True), patch.object( + vd.shutil, "which", return_value=None + ), patch.dict(sys.modules, {"imageio_ffmpeg": fake_imageio_ffmpeg}): + self.assertEqual(vd.get_ffmpeg_binary(), "/tmp/bundled-ffmpeg") + + def test_open_video_clip_quietly_suppresses_moviepy_stdout(self): + """ + MoviePy 2.1.x 的 FFMPEG_VideoReader 会直接向 stdout 打印 metadata + 和 ffmpeg 命令。项目服务层应屏蔽这类依赖库噪声,避免用户把 + `audio_found: False` 误判为最终视频没有音频。 + """ + video_path = os.path.join(resources_dir, "1.png.mp4") + if not os.path.exists(video_path): + self.fail(f"test video not found: {video_path}") + + stdout = StringIO() + with redirect_stdout(stdout): + clip = vd._open_video_clip_quietly(video_path) + + try: + self.assertEqual(stdout.getvalue(), "") + self.assertIsNone(clip.audio) + self.assertGreater(clip.duration, 0) + finally: + vd.close_clip(clip) + + def test_combine_videos_closes_audio_clip_when_duration_read_fails(self): + """ + `combine_videos()` 只需要读取旁白音频时长。即使读取 duration + 时发生异常,也必须关闭 AudioFileClip,避免文件句柄泄漏。 + """ + + class _FakeAudioReader: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + class _BrokenAudioClip: + def __init__(self): + self.reader = _FakeAudioReader() + + @property + def duration(self): + raise RuntimeError("failed to read duration") + + fake_audio_clip = _BrokenAudioClip() + + with patch.object(vd, "AudioFileClip", return_value=fake_audio_clip): + with self.assertRaises(RuntimeError): + vd.combine_videos( + combined_video_path="/tmp/unused-combined.mp4", + video_paths=[], + audio_file="/tmp/unused-audio.mp3", + ) + + self.assertTrue(fake_audio_clip.reader.closed) + + def test_combine_videos_handles_none_transition_mode(self): + """ + Ensure `combine_videos` safely handles + `video_transition_mode=None`. + """ + class _FakeAudioClip: + @property + def duration(self): + return 10.0 + + def close(self): + pass + + with tempfile.TemporaryDirectory() as temp_dir: + combined_video_path = os.path.join(temp_dir, "combined.mp4") + audio_file = os.path.join(temp_dir, "audio.mp3") + + with patch.object(vd, "AudioFileClip", return_value=_FakeAudioClip()): + # Use empty video_paths to avoid heavy video processing while + # still exercising transition mode normalization logic. + result = vd.combine_videos( + combined_video_path=combined_video_path, + video_paths=[], + audio_file=audio_file, + video_transition_mode=None, + ) + self.assertEqual(result, combined_video_path) + + def test_wrap_text(self): + """test text wrapping function""" + try: + font_path = os.path.join(utils.font_dir(), "STHeitiMedium.ttc") + if not os.path.exists(font_path): + self.fail(f"font file not found: {font_path}") + + # test english text wrapping + test_text_en = "This is a test text for wrapping long sentences in english language" + + wrapped_text_en, text_height_en = vd.wrap_text( + text=test_text_en, + max_width=300, + font=font_path, + fontsize=30 + ) + print(wrapped_text_en, text_height_en) + # verify text is wrapped + self.assertIn("\n", wrapped_text_en) + + # test chinese text wrapping + test_text_zh = "这是一段用来测试中文长句换行的文本内容,应该会根据宽度限制进行换行处理" + wrapped_text_zh, text_height_zh = vd.wrap_text( + text=test_text_zh, + max_width=300, + font=font_path, + fontsize=30 + ) + print(wrapped_text_zh, text_height_zh) + # verify chinese text is wrapped + self.assertIn("\n", wrapped_text_zh) + except Exception as e: + self.fail(f"test wrap_text failed: {str(e)}") + +if __name__ == "__main__": + unittest.main() diff --git a/test/services/test_voice.py b/test/services/test_voice.py new file mode 100644 index 0000000..2c08ea8 --- /dev/null +++ b/test/services/test_voice.py @@ -0,0 +1,487 @@ +import asyncio +import base64 +import unittest +import os +import sys +import tempfile +import time +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +# add project root to python path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from app.utils import utils +from app.services import voice as vs +from app.services import task as task_service +from pydub import AudioSegment + +temp_dir = utils.storage_dir("temp") + +text_en = """ +What is the meaning of life? +This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries. +Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life. +Some say it's to seek happiness and self-fulfillment, while others believe it's about contributing to the welfare of others and making a positive impact in the world. +Despite the myriad of perspectives, one thing remains clear: the meaning of life is a deeply personal concept that varies from one person to another. +It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence. +""" + +text_zh = """ +预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具; +10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉; +12日天气短暂好转,早晚清凉; +""" + +voice_rate=1.0 +voice_volume=1.0 + +class TestVoiceService(unittest.TestCase): + def setUp(self): + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + def tearDown(self): + self.loop.close() + + def test_siliconflow(self): + # SiliconFlow 的 API Key 存在 [siliconflow].api_key 中,运行时代码也是从 + # config.siliconflow 读取;这里必须使用同一配置源,避免正确配置凭据时 + # 测试仍然被误跳过。 + if not vs.config.siliconflow.get("api_key"): + self.skipTest("siliconflow_api_key is not configured") + + voice_name = "siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex-Male" + voice_name = vs.parse_voice_name(voice_name) + + async def _do(): + parts = voice_name.split(":") + if len(parts) >= 3: + model = parts[1] + # 移除性别后缀,例如 "alex-Male" -> "alex" + voice_with_gender = parts[2] + voice = voice_with_gender.split("-")[0] + # 构建完整的voice参数,格式为 "model:voice" + full_voice = f"{model}:{voice}" + voice_file = f"{temp_dir}/tts-siliconflow-{voice}.mp3" + subtitle_file = f"{temp_dir}/tts-siliconflow-{voice}.srt" + sub_maker = vs.siliconflow_tts( + text=text_zh, model=model, voice=full_voice, voice_file=voice_file, voice_rate=voice_rate, voice_volume=voice_volume + ) + if not sub_maker: + self.fail("siliconflow tts failed") + vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file) + audio_duration = vs.get_audio_duration(sub_maker) + print(f"voice: {voice_name}, audio duration: {audio_duration}s") + else: + self.fail("siliconflow invalid voice name") + + self.loop.run_until_complete(_do()) + + def test_azure_tts_v1(self): + voice_name = "zh-CN-XiaoyiNeural-Female" + voice_name = vs.parse_voice_name(voice_name) + print(voice_name) + + voice_file = f"{temp_dir}/tts-azure-v1-{voice_name}.mp3" + subtitle_file = f"{temp_dir}/tts-azure-v1-{voice_name}.srt" + sub_maker = vs.azure_tts_v1( + text=text_zh, voice_name=voice_name, voice_file=voice_file, voice_rate=voice_rate + ) + if not sub_maker: + self.fail("azure tts v1 failed") + vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file) + audio_duration = vs.get_audio_duration(sub_maker) + print(f"voice: {voice_name}, audio duration: {audio_duration}s") + + def test_azure_tts_v1_supports_legacy_edge_tts_without_boundary(self): + """ + 验证 Azure TTS V1 在旧版 edge_tts 依赖残留时仍可继续工作。 + + 这个回归场景对应 Windows 便携包更新失败后,现场环境还停留在旧版 + edge_tts 的情况: + 1. `Communicate.__init__()` 不接受 `boundary` + 2. 只有异步 `stream()`,没有 `stream_sync()` + """ + + class _LegacyCommunicate: + def __init__(self, text, voice, rate="+0%"): + self.text = text + self.voice = voice + self.rate = rate + + async def stream(self): + yield {"type": "audio", "data": b"legacy-audio"} + yield { + "type": "WordBoundary", + "offset": 0, + "duration": 10000000, + "text": "legacy", + } + + class _FakeSubMaker: + def __init__(self): + self.events = [] + + def feed(self, chunk): + self.events.append(chunk) + + def get_srt(self): + if not self.events: + return "" + return "1\n00:00:00,000 --> 00:00:01,000\nlegacy\n" + + with tempfile.TemporaryDirectory() as tmp_dir, patch.object( + vs.edge_tts, "Communicate", _LegacyCommunicate + ), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker): + voice_file = str(Path(tmp_dir) / "legacy-edge-tts.mp3") + sub_maker = vs.azure_tts_v1( + text="legacy edge tts compatibility", + voice_name="zh-CN-XiaoyiNeural-Female", + voice_file=voice_file, + voice_rate=1.0, + ) + + self.assertIsNotNone(sub_maker) + self.assertEqual(Path(voice_file).read_bytes(), b"legacy-audio") + self.assertEqual(len(sub_maker.events), 1) + self.assertEqual(sub_maker.events[0]["type"], "WordBoundary") + + def test_azure_tts_v1_times_out_hanging_stream_sync(self): + """ + 验证 Azure TTS V1 在 edge_tts 同步流卡住时能够快速失败。 + + 真实现场里,网络异常、服务端限流、voice 语言与文本不匹配时, + `stream_sync()` 可能长时间不返回,导致 WebUI 任务只停在 + `start, voice name...`。这里用阻塞的 fake stream 复现该场景, + 确认超时保护会让函数结束并返回 None。 + """ + + class _HangingCommunicate: + def __init__(self, text, voice, rate="+0%", boundary=None): + self.text = text + self.voice = voice + self.rate = rate + self.boundary = boundary + + def stream_sync(self): + time.sleep(10) + yield {"type": "audio", "data": b"unreachable"} + + class _FakeSubMaker: + def feed(self, chunk): + return None + + def get_srt(self): + return "" + + with tempfile.TemporaryDirectory() as tmp_dir, patch.object( + vs.edge_tts, "Communicate", _HangingCommunicate + ), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker), patch.object( + vs.config, + "app", + dict(vs.config.app, edge_tts_timeout=0.05), + ): + voice_file = Path(tmp_dir) / "hanging-edge-tts.mp3" + started_at = time.monotonic() + sub_maker = vs.azure_tts_v1( + text="帮我生成一个花开花落的视频", + voice_name="en-AU-NatashaNeural-Female", + voice_file=str(voice_file), + voice_rate=1.0, + ) + elapsed = time.monotonic() - started_at + self.assertFalse(voice_file.exists()) + + self.assertIsNone(sub_maker) + self.assertLess(elapsed, 2) + + def test_azure_tts_v2(self): + if not vs.config.azure.get("speech_key") or not vs.config.azure.get("speech_region"): + self.skipTest("Azure speech key or region is not configured") + + voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female" + voice_name = vs.parse_voice_name(voice_name) + print(voice_name) + + async def _do(): + voice_file = f"{temp_dir}/tts-azure-v2-{voice_name}.mp3" + subtitle_file = f"{temp_dir}/tts-azure-v2-{voice_name}.srt" + sub_maker = vs.azure_tts_v2( + text=text_zh, voice_name=voice_name, voice_file=voice_file + ) + if not sub_maker: + self.fail("azure tts v2 failed") + vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file) + audio_duration = vs.get_audio_duration(sub_maker) + print(f"voice: {voice_name}, audio duration: {audio_duration}s") + + self.loop.run_until_complete(_do()) + + def test_gemini_tts_uses_legacy_submaker_fields(self): + """ + 验证 Gemini TTS 在 edge_tts 7.x 环境下仍会返回项目兼容的字幕结构, + 并且可以被 `subtitle_provider=edge` 的字幕生成链路直接消费, + 避免再次回退 Whisper。 + """ + + class _InlineData: + def __init__(self, data): + self.data = data + + class _Part: + def __init__(self, data): + self.inline_data = _InlineData(data) + + class _Content: + def __init__(self, data): + self.parts = [_Part(data)] + + class _Candidate: + def __init__(self, data): + self.content = _Content(data) + + class _Response: + def __init__(self, data): + self.candidates = [_Candidate(data)] + + class _FakeModel: + def __init__(self, name): + self.name = name + + def generate_content(self, contents, generation_config): + tone = ( + AudioSegment.silent(duration=1800) + .set_frame_rate(24000) + .set_channels(1) + .set_sample_width(2) + ) + return _Response(tone.raw_data) + + voice_file = f"{temp_dir}/tts-gemini-Zephyr.mp3" + subtitle_file = f"{temp_dir}/tts-gemini-Zephyr.srt" + text = "Gemini subtitle generation should work now. Testing multiple lines." + + with patch("google.generativeai.configure"), patch( + "google.generativeai.GenerativeModel", _FakeModel + ), patch.object(vs.config, "app", dict(vs.config.app, gemini_api_key="test-key")): + sub_maker = vs.gemini_tts( + text=text, + voice_name="Zephyr", + voice_rate=1.0, + voice_file=voice_file, + ) + + self.assertIsNotNone(sub_maker) + self.assertEqual( + getattr(sub_maker, "subs", []), + ["Gemini subtitle generation should work now", "Testing multiple lines"], + ) + self.assertEqual(len(getattr(sub_maker, "offset", [])), 2) + self.assertEqual(sub_maker.offset[0][0], 0) + self.assertLess(sub_maker.offset[0][1], sub_maker.offset[1][1]) + + vs.create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file) + subtitle_content = Path(subtitle_file).read_text(encoding="utf-8") + self.assertIn("Gemini subtitle generation should work now", subtitle_content) + self.assertIn("Testing multiple lines", subtitle_content) + + def test_mimo_tts_uses_openai_compatible_audio_response(self): + """ + 验证 Xiaomi MiMo TTS 可以消费 OpenAI-compatible 的音频响应结构。 + + 这里用 fake OpenAI client 和 fake AudioSegment 覆盖真实网络与 ffmpeg, + 确认运行时代码会把待合成文本放到 assistant message,并把返回的 + base64 WAV 音频导出到项目后续流程使用的音频文件。 + """ + + class _FakeAudio: + def __init__(self): + self.data = base64.b64encode(b"RIFF-fake-wav").decode("utf-8") + + class _FakeMessage: + def __init__(self): + self.audio = _FakeAudio() + + class _FakeChoice: + def __init__(self): + self.message = _FakeMessage() + + class _FakeCompletion: + def __init__(self): + self.choices = [_FakeChoice()] + + class _FakeCompletions: + def create(self, **kwargs): + self.kwargs = kwargs + return _FakeCompletion() + + class _FakeAudioSegment: + def __len__(self): + return 1800 + + def export(self, output_file, format): + Path(output_file).write_bytes(b"fake-mp3") + + fake_completions = _FakeCompletions() + fake_client = SimpleNamespace( + chat=SimpleNamespace(completions=fake_completions) + ) + + with tempfile.TemporaryDirectory() as tmp_dir, patch.object( + vs, + "OpenAI", + return_value=fake_client, + ) as openai_client, patch( + "pydub.AudioSegment.from_file", + return_value=_FakeAudioSegment(), + ), patch.object( + vs.config, + "app", + dict( + vs.config.app, + mimo_api_key="mimo-key", + mimo_base_url="https://api.xiaomimimo.com/v1", + mimo_tts_model_name="mimo-v2.5-tts", + mimo_tts_style_prompt="用清晰的中文旁白朗读。", + ), + ): + voice_file = str(Path(tmp_dir) / "mimo-tts.mp3") + sub_maker = vs.mimo_tts( + text="小米语音合成测试。第二句话。", + voice_name="冰糖", + voice_rate=1.0, + voice_file=voice_file, + voice_volume=1.0, + ) + generated_audio = Path(voice_file).read_bytes() + + openai_client.assert_called_once_with( + api_key="mimo-key", + base_url="https://api.xiaomimimo.com/v1", + ) + self.assertEqual(fake_completions.kwargs["model"], "mimo-v2.5-tts") + self.assertEqual( + fake_completions.kwargs["messages"], + [ + {"role": "user", "content": "用清晰的中文旁白朗读。"}, + {"role": "assistant", "content": "小米语音合成测试。第二句话。"}, + ], + ) + self.assertEqual( + fake_completions.kwargs["audio"], + {"format": "wav", "voice": "冰糖"}, + ) + self.assertEqual(generated_audio, b"fake-mp3") + self.assertIsNotNone(sub_maker) + self.assertEqual(getattr(sub_maker, "subs", []), ["小米语音合成测试", "第二句话"]) + self.assertEqual(len(getattr(sub_maker, "offset", [])), 2) + + def test_generate_subtitle_keeps_edge_provider_for_gemini_legacy_submaker(self): + """ + 验证 Gemini TTS 返回的 legacy 字幕结构在 edge provider 下可以直接产出 + SRT,不会因为匹配失败而回退到 Whisper。 + """ + script = "Gemini subtitle generation should work now. Testing multiple lines." + sub_maker = vs.populate_legacy_submaker_with_full_text( + vs.ensure_legacy_submaker_fields(vs.SubMaker()), + script, + 2.4, + ) + + with tempfile.TemporaryDirectory() as tmp_dir, patch.object( + task_service.config, + "app", + dict(task_service.config.app, subtitle_provider="edge"), + ), patch("app.services.subtitle.create") as whisper_create, patch( + "app.utils.utils.task_dir", + lambda tid="": str(Path(tmp_dir) / tid) if tid else str(Path(tmp_dir)), + ): + task_id = "gemini-subtitle-edge-task" + Path(tmp_dir, task_id).mkdir(parents=True, exist_ok=True) + subtitle_path = task_service.generate_subtitle( + task_id=task_id, + params=type("Params", (), {"subtitle_enabled": True})(), + video_script=script, + sub_maker=sub_maker, + audio_file="", + ) + + self.assertTrue(subtitle_path.endswith("subtitle.srt")) + self.assertTrue(Path(subtitle_path).exists()) + self.assertFalse(whisper_create.called) + subtitle_content = Path(subtitle_path).read_text(encoding="utf-8") + self.assertIn("Gemini subtitle generation should work now", subtitle_content) + self.assertIn("Testing multiple lines", subtitle_content) + + def test_script_split_keeps_thousand_separator_comma(self): + """ + Edge TTS 会把 "1,000 years" 作为连续文本返回。脚本断句时不能把 + 数字中间的英文逗号当成句子边界,否则字幕聚合会出现 issue #894 + 里的 sub_items 数量少于 script_lines,并错误回退 Whisper。 + """ + text = ( + "It takes about 1,000 years for a single drop of water to finish " + "the whole trip!" + ) + + self.assertEqual( + utils.split_string_by_punctuations(text), + [ + ( + "It takes about 1,000 years for a single drop of water to finish " + "the whole trip" + ) + ], + ) + + def test_edge_cue_aggregation_handles_thousand_separator_comma(self): + """ + 复现 issue #894 的关键形态:Edge cues 中最后一句作为连续文本返回, + 包含 `1,000 years`。脚本断句必须与 cues 聚合结果一致,不能把它 + 拆成两条字幕。 + """ + text = ( + "The ocean isn't just sitting stil, it moves around the world like a massive " + "amusement park ride! Cold water at the North and South Poles sinks to the " + "bottom because it is heavy and salty. At the same time, warm water from the " + "sunny equator flows along the top to take its place. This creates a giant " + "underwater conveyor belt that travels all the way around the Earth. It takes " + "about 1,000 years for a single drop of water to finish the whole trip!" + ) + script_lines = utils.split_string_by_punctuations(text) + cues = [] + for index, line in enumerate(script_lines): + # Edge 的 cue content 经常没有脚本里的空格和标点布局,这里去掉空格 + # 来模拟更严格的匹配场景。 + cues.append( + SimpleNamespace( + content=line.replace(" ", ""), + start=timedelta(seconds=index), + end=timedelta(seconds=index + 0.8), + ) + ) + sub_maker = SimpleNamespace(cues=cues) + + sub_items = vs._build_subtitle_items_from_edge_cues(sub_maker, script_lines) + + self.assertEqual(len(sub_items), len(script_lines)) + self.assertIn("1,000 years", sub_items[-1]) + + def test_convert_rate_to_percent_signs_zero_rate(self): + # Rates near but not exactly 1.0 round to 0 percent. edge-tts rejects + # an unsigned "0%" (ValueError: Invalid rate '0%'), so the helper must + # emit a sign-prefixed "+0%". Regression test for that crash. + self.assertEqual(vs.convert_rate_to_percent(1.0), "+0%") + self.assertEqual(vs.convert_rate_to_percent(1.004), "+0%") + self.assertEqual(vs.convert_rate_to_percent(0.997), "+0%") + self.assertEqual(vs.convert_rate_to_percent(1.5), "+50%") + self.assertEqual(vs.convert_rate_to_percent(0.8), "-20%") + + +if __name__ == "__main__": + # python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v1 + # python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v2 + unittest.main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b81562b --- /dev/null +++ b/uv.lock @@ -0,0 +1,2178 @@ +version = 1 +revision = 1 +requires-python = ">=3.11, <3.13" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513 }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748 }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673 }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757 }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152 }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010 }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969 }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871 }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844 }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969 }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193 }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477 }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198 }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321 }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069 }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859 }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876 }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557 }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258 }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199 }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013 }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501 }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981 }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934 }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671 }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219 }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049 }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557 }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931 }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125 }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427 }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534 }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446 }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, +] + +[[package]] +name = "altair" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/b1/f2969c7bdb8ad8bbdda031687defdce2c19afba2aa2c8e1d2a17f78376d8/altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d", size = 705305 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f3/0b6ced594e51cc95d8c1fc1640d3623770d01e4969d29c0bd09945fafefa/altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c", size = 731200 }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, +] + +[[package]] +name = "av" +version = "17.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/eb/abca886df3a091bc406feb5ff71b4c4f426beaae6b71b9697264ce8c7211/av-17.0.0.tar.gz", hash = "sha256:c53685df73775a8763c375c7b2d62a6cb149d992a26a4b098204da42ade8c3df", size = 4410769 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/fb/55e3b5b5d1fc61466292f26fbcbabafa2642f378dc48875f8f554591e1a4/av-17.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ed4013fac77c309a4a68141dcf6148f1821bb1073a36d4289379762a6372f711", size = 23238424 }, + { url = "https://files.pythonhosted.org/packages/52/03/9ace1acc08bc9ae38c14bf3a4b1360e995e4d999d1d33c2cbd7c9e77582a/av-17.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:e44b6c83e9f3be9f79ee87d0b77a27cea9a9cd67bd630362c86b7e56a748dfbb", size = 18709043 }, + { url = "https://files.pythonhosted.org/packages/00/c0/637721f3cd5bb8bd16105a1a08efd781fc12f449931bdb3a4d0cfd63fa55/av-17.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b440da6ac47da0629d509316f24bcd858f33158dbdd0f1b7293d71e99beb26de", size = 34018780 }, + { url = "https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1060cba85f97f4a337311169d92c0b5e143452cfa5ca0e65fa499d7955e8592e", size = 36358757 }, + { url = "https://files.pythonhosted.org/packages/52/6c/a1f4f2677bae6f2ade7a8a18e90ebdcf70690c9b1c4e40e118aa30fa313f/av-17.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:deda202e6021cfc7ba3e816897760ec5431309d59a4da1f75df3c0e9413d71e7", size = 35195281 }, + { url = "https://files.pythonhosted.org/packages/90/ea/52b0fc6f69432c7bf3f5fbe6f707113650aa40a1a05b9096ffc2bba4f77d/av-17.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffaf266a1a9c2148072de0a4b5ae98061465178d2cfaa69ee089761149342974", size = 37444817 }, + { url = "https://files.pythonhosted.org/packages/34/ad/d2172966282cb8f146c13b6be7416efefde74186460c5e1708ddfc13dba6/av-17.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:45a35a40b2875bf2f98de7c952d74d960f92f319734e6d28e03b4c62a49e6f49", size = 28888553 }, + { url = "https://files.pythonhosted.org/packages/b0/bb/c5a4c4172c514d631fb506e6366b503576b8c7f29809cf42aca73e28ff01/av-17.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:3d32e9b5c5bbcb872a0b6917b352a1db8a42142237826c9b49a36d5dbd9e9c26", size = 21916910 }, +] + +[[package]] +name = "azure-cognitiveservices-speech" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/d7/a543d16a2c8856327564f1d1698888f978e08646716cd6396f304562b2f8/azure_cognitiveservices_speech-1.41.1-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:ea9d466f236598e37ea3dad1db203a2901ef91b407e435b59d9b22669324074d", size = 7419245 }, + { url = "https://files.pythonhosted.org/packages/6d/78/79ef2ca832d35c090cc3f89bb74e5dcc67215d02a86d98a527a4001c77f6/azure_cognitiveservices_speech-1.41.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0f52f7852965bb2f5cf9aed0d3c6ef58238867bb6f0287eba95e42e1a513dd74", size = 7275748 }, + { url = "https://files.pythonhosted.org/packages/be/a4/3768fd51a1339c758cb1be5e445ec1571147d2f17f62ca4b96ce9e870296/azure_cognitiveservices_speech-1.41.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:70030c6f1c875895eb985de3775f62349aa8687b6616afa9498466e281f178d3", size = 39712952 }, + { url = "https://files.pythonhosted.org/packages/24/33/160a9f334b64cd255da953a7756bdefe4a55f5295447597de052c1601c25/azure_cognitiveservices_speech-1.41.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:94ddda0deb3a9fee58a0a781b09ab8ab95401c5daf9bfc9f84ce8134d3a77055", size = 39495162 }, + { url = "https://files.pythonhosted.org/packages/9f/f4/e0c7311aedbfa2636a63a47dfb0d94ad142e2e72885e2526cb119b14b66d/azure_cognitiveservices_speech-1.41.1-py3-none-win32.whl", hash = "sha256:039eec52c0a549a30658fa24a06d42afc6366c47b03b961c0b6f730fd421293e", size = 2108337 }, + { url = "https://files.pythonhosted.org/packages/eb/a8/b4ce0c253c43c1ba44d795bc97910b800e8ff9991216666eb79b728b35e4/azure_cognitiveservices_speech-1.41.1-py3-none-win_amd64.whl", hash = "sha256:13679949f52f89c263e8b1c6a2d0f384d663917c58b150772cf42b710a01321c", size = 2376756 }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110 }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438 }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420 }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619 }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014 }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661 }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150 }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505 }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451 }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035 }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543 }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288 }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071 }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913 }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762 }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494 }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302 }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913 }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362 }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115 }, +] + +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401 }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275 }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320 }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082 }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514 }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766 }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535 }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618 }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802 }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425 }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530 }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896 }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348 }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896 }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776 }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529 }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827 }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265 }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800 }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771 }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333 }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069 }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358 }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061 }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103 }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255 }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660 }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160 }, + { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444 }, + { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227 }, + { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399 }, + { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595 }, + { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912 }, + { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955 }, +] + +[[package]] +name = "ctranslate2" +version = "4.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/25/41920ccee68e91cb6fa0fc9e8078ab2b7839f2c668f750dc123144cb7c6e/ctranslate2-4.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f74200bab9996b14a57cf6f7cb27d0921ceedc4acc1e905598e3e85b4d75b1ec", size = 1256943 }, + { url = "https://files.pythonhosted.org/packages/79/22/bc81fcc9f10ba4da3ffd1a9adec15cfb73cb700b3bbe69c6c8b55d333316/ctranslate2-4.7.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:59b427eb3ac999a746315b03a63942fddd351f511db82ba1a66880d4dea98e25", size = 11916445 }, + { url = "https://files.pythonhosted.org/packages/0a/a7/494a66bb02c7926331cadfff51d5ce81f5abfb1e8d05d7f2459082f31b48/ctranslate2-4.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95f0c1051c180669d2a83a44b44b518b2d1683de125f623bbc81ad5dd6f6141c", size = 16696997 }, + { url = "https://files.pythonhosted.org/packages/ed/4e/b48f79fd36e5d3c7e12db383aa49814c340921a618ef7364bd0ced670644/ctranslate2-4.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed92d9ab0ac6bc7005942be83d68714c80adb0897ab17f98157294ee0374347", size = 38836379 }, + { url = "https://files.pythonhosted.org/packages/d2/23/8c01ac52e1f26fc4dbe985a35222ae7cd365bbf7ee5db5fd5545d8926f91/ctranslate2-4.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:67d9ad9b69933fbfeee7dcec899b2cd9341d5dca4fdfb53e8ba8c109dc332ee1", size = 18843315 }, + { url = "https://files.pythonhosted.org/packages/fc/0f/581de94b64c5f2327a736270bc7e7a5f8fe5cf1ed56a2203b52de4d8986a/ctranslate2-4.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c0cbd46a23b8dc37ccdbd9b447cb5f7fadc361c90e9df17d82ca84b1f019986", size = 1257089 }, + { url = "https://files.pythonhosted.org/packages/3d/e9/d55b0e436362f9fe26bd98fefd2dd5d81926121f1d7f799c805e6035bb26/ctranslate2-4.7.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:5b141ddad1da5f84cf3c2a569a56227a37de649a555d376cbd9b80e8f0373dd8", size = 11918502 }, + { url = "https://files.pythonhosted.org/packages/ec/ce/9f29f0b0bb4280c2ebafb3ddb6cdff8ef1c2e185ee020c0ec0ecba7dc934/ctranslate2-4.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00a62544db4a3caaa58a3c50d39b25613c042b430053ae32384d94eb1d40990", size = 16859601 }, + { url = "https://files.pythonhosted.org/packages/b3/86/428d270fd72117d19fb48ed3211aa8a3c8bd7577373252962cb634e0fd01/ctranslate2-4.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:722b93a89647974cbd182b4c7f87fefc7794fff7fc9cbd0303b6447905cc157e", size = 38995338 }, + { url = "https://files.pythonhosted.org/packages/4a/f4/d23dbfb9c62cb642c114a30f05d753ba61d6ffbfd8a3a4012fe85a073bcb/ctranslate2-4.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f734dc3757118094663bdaaf713f5090c55c1927fb330a76bb8b84173940e8", size = 18844949 }, +] + +[[package]] +name = "dashscope" +version = "1.20.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "requests" }, + { name = "websocket-client" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/ab/06425bc51305d6acd19cf04736f0fac0cc34b8ced6f31f2d41909a2e1920/dashscope-1.20.14-py3-none-any.whl", hash = "sha256:1e20f56043150ac0adb70c5ec04ac202d87267ca8da63e5c94980dd10aeab7af", size = 1264547 }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, +] + +[[package]] +name = "edge-tts" +version = "7.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "tabulate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/d2/1ce38f6e4fe7275207f4033b0971db489a0b594340ae6bac2320127e71ee/edge_tts-7.2.7.tar.gz", hash = "sha256:0127fba57a742bc48ff0a2a3b24b8324f7859260185274c335b4e54735aff325", size = 27508 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/89/92ac6b154ab87d236c15e5e0c73cb99be58efb1ea3eb9318c266bf9a36bf/edge_tts-7.2.7-py3-none-any.whl", hash = "sha256:ac11d9e834347e5ee62cbe72e8a56ffd65d3c4e795be14b1e593b72cf6480dd9", size = 30556 }, +] + +[[package]] +name = "fastapi" +version = "0.115.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/72/d83b98cd106541e8f5e5bfab8ef2974ab45a62e8a6c5b5e6940f26d2ed4b/fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654", size = 301336 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/b3/7e4df40e585df024fac2f80d1a2d579c854ac37109675db2b0cc22c0bb9e/fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305", size = 94843 }, +] + +[[package]] +name = "faster-whisper" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/b1/124f6d5a547756170e11eea405ae6c08afa2b96e8ccd10947a1244b50cdb/faster-whisper-1.1.0.tar.gz", hash = "sha256:cea4bba5d4527173fdbacafa56f2ffb17dd322688f6c3fdf5fd7b6b6c193ce17", size = 1124950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/03/ab118cb743dcf671da01ad0cfd7564465dda115db32976fdc95e21ce8feb/faster_whisper-1.1.0-py3-none-any.whl", hash = "sha256:0f2d025676bbff1e46c4108b6f9a82578d6e33826c174af2990e45b33fab6182", size = 1118168 }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759 }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661 }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912 }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046 }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119 }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067 }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160 }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544 }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797 }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886 }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731 }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544 }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806 }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382 }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647 }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064 }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937 }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, +] + +[[package]] +name = "fsspec" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595 }, +] + +[[package]] +name = "g4f" +version = "0.5.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "brotli" }, + { name = "nest-asyncio" }, + { name = "pycryptodome" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/2a/dfba533ec8bcf1a390f26834a97fe5da7650936f1e366554f69252e1dcda/g4f-0.5.2.2.tar.gz", hash = "sha256:f12c97bb747d9500eaefa269e92280a7ea2db91123d33b5dfc125c07b72e7dbd", size = 341955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/81/6455fbff6db66c3ccf546e5790c266f8195aa283faa3b4696cd28ede395e/g4f-0.5.2.2-py3-none-any.whl", hash = "sha256:72cf73c7aad49d0e57ee48e7ebf9d3cbd74ea083461b10c36c293cc5a799b383", size = 450589 }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620 }, +] + +[[package]] +name = "google-ai-generativelanguage" +version = "0.6.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/d1/48fe5d7a43d278e9f6b5ada810b0a3530bbeac7ed7fcbcd366f932f05316/google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3", size = 1375443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a3/67b8a6ff5001a1d8864922f2d6488dc2a14367ceb651bc3f09a947f2f306/google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c", size = 1327356 }, +] + +[[package]] +name = "google-api-core" +version = "2.30.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/2e/83ca41eb400eb228f9279ec14ed66f6475218b59af4c6daec2d5a509fe83/google_api_core-2.30.2.tar.gz", hash = "sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9", size = 176862 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/e1/ebd5100cbb202e561c0c8b59e485ef3bd63fa9beb610f3fdcaea443f0288/google_api_core-2.30.2-py3-none-any.whl", hash = "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594", size = 173236 }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.193.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/f4/e14b6815d3b1885328dd209676a3a4c704882743ac94e18ef0093894f5c8/google_api_python_client-2.193.0.tar.gz", hash = "sha256:8f88d16e89d11341e0a8b199cafde0fb7e6b44260dffb88d451577cbd1bb5d33", size = 14281006 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/6d/fe75167797790a56d17799b75e1129bb93f7ff061efc7b36e9731bd4be2b/google_api_python_client-2.193.0-py3-none-any.whl", hash = "sha256:c42aa324b822109901cfecab5dc4fc3915d35a7b376835233c916c70610322db", size = 14856490 }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737 }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/99/107612bef8d24b298bb5a7c8466f908ecda791d43f9466f5c3978f5b24c1/google_auth_httplib2-0.3.1.tar.gz", hash = "sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55", size = 11152 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/e9/93afb14d23a949acaa3f4e7cc51a0024671174e116e35f42850764b99634/google_auth_httplib2-0.3.1-py3-none-any.whl", hash = "sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c", size = 9534 }, +] + +[[package]] +name = "google-generativeai" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-ai-generativelanguage" }, + { name = "google-api-core" }, + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/0f/ef33b5bb71437966590c6297104c81051feae95d54b11ece08533ef937d3/google_generativeai-0.8.6-py3-none-any.whl", hash = "sha256:37a0eaaa95e5bbf888828e20a4a1b2c196cc9527d194706e58a68ff388aeb0fa", size = 155098 }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743 }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009 }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295 }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297 }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208 }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442 }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743 }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046 }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641 }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368 }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235 }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616 }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204 }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866 }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060 }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121 }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811 }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860 }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132 }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904 }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944 }, +] + +[[package]] +name = "grpcio-status" +version = "1.71.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "hf-xet" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048 }, + { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178 }, + { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320 }, + { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546 }, + { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200 }, + { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392 }, + { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359 }, + { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + +[[package]] +name = "huggingface-hub" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/2a/a847fd02261cd051da218baf99f90ee7c7040c109a01833db4f838f25256/huggingface_hub-1.8.0.tar.gz", hash = "sha256:c5627b2fd521e00caf8eff4ac965ba988ea75167fad7ee72e17f9b7183ec63f3", size = 735839 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ae/8a3a16ea4d202cb641b51d2681bdd3d482c1c592d7570b3fa264730829ce/huggingface_hub-1.8.0-py3-none-any.whl", hash = "sha256:d3eb5047bd4e33c987429de6020d4810d38a5bef95b3b40df9b17346b7f353f2", size = 625208 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646 }, +] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969 }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891 }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706 }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237 }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251 }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824 }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157 }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729 }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766 }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587 }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537 }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717 }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683 }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345 }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775 }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325 }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709 }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560 }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608 }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958 }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597 }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821 }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163 }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709 }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480 }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735 }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814 }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990 }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021 }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024 }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424 }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818 }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016 }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024 }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337 }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395 }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169 }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808 }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384 }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768 }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + +[[package]] +name = "litellm" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/02/dd7191498805a6a1b5577993b0e01b5edff7b934fa95fbced4b1f7e09be2/litellm-1.60.0.tar.gz", hash = "sha256:45e3d9d7c19c02b7a1adf1c86102395fc667ebf35f72033e490e8f6fae7d0f8e", size = 6437939 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/db/382c5e34025acb2a01e02d8ce3e1b675d41a29b2e5c694435272675f6fb2/litellm-1.60.0-py3-none-any.whl", hash = "sha256:e8284f5cc74ae1aed77516cdfbb2db3746d50c4ba055fb21358e6e25aea25a15", size = 6732210 }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "moneyprinterturbo" +version = "1.2.9" +source = { virtual = "." } +dependencies = [ + { name = "azure-cognitiveservices-speech" }, + { name = "dashscope" }, + { name = "edge-tts" }, + { name = "fastapi" }, + { name = "faster-whisper" }, + { name = "google-generativeai" }, + { name = "litellm" }, + { name = "loguru" }, + { name = "moviepy" }, + { name = "openai" }, + { name = "pydub" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "socksio" }, + { name = "streamlit" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +g4f = [ + { name = "g4f" }, +] + +[package.metadata] +requires-dist = [ + { name = "azure-cognitiveservices-speech", specifier = "==1.41.1" }, + { name = "dashscope", specifier = "==1.20.14" }, + { name = "edge-tts", specifier = "==7.2.7" }, + { name = "fastapi", specifier = "==0.115.6" }, + { name = "faster-whisper", specifier = "==1.1.0" }, + { name = "g4f", marker = "extra == 'g4f'", specifier = "==0.5.2.2" }, + { name = "google-generativeai", specifier = "==0.8.6" }, + { name = "litellm", specifier = "==1.60.0" }, + { name = "loguru", specifier = "==0.7.3" }, + { name = "moviepy", specifier = "==2.1.2" }, + { name = "openai", specifier = "==1.56.1" }, + { name = "pydub", specifier = "==0.25.1" }, + { name = "python-multipart", specifier = "==0.0.19" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "redis", specifier = "==5.2.0" }, + { name = "requests", specifier = "==2.33.1" }, + { name = "socksio", specifier = "==1.0.0" }, + { name = "streamlit", specifier = "==1.45.0" }, + { name = "uvicorn", specifier = "==0.32.1" }, +] +provides-extras = ["g4f"] + +[[package]] +name = "moviepy" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "imageio" }, + { name = "imageio-ffmpeg" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "proglog" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/fd/4afd90d35ea00dd82e67b7dcbbb2994eae97a2d24a0704c3f01505da4a1c/moviepy-2.1.2.tar.gz", hash = "sha256:22c57a7472f607eaad9fe80791df67c05082e1060fb74817c4eaac68e138ee77", size = 58425716 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ee/ef46fdfbdc7e1316da60142ffc1867e6e12a22341636dce62856bb325272/moviepy-2.1.2-py3-none-any.whl", hash = "sha256:6cdc0d739110c8f347a224d72bd59eebaec010720d01eff290d37111bf545a73", size = 126708 }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626 }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706 }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356 }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355 }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433 }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376 }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365 }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747 }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293 }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962 }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360 }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940 }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502 }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065 }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870 }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302 }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981 }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159 }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, +] + +[[package]] +name = "narwhals" +version = "2.18.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/96/45218c2fdec4c9f22178f905086e85ef1a6d63862dcc3cd68eb60f1867f5/narwhals-2.18.1.tar.gz", hash = "sha256:652a1fcc9d432bbf114846688884c215f17eb118aa640b7419295d2f910d2a8b", size = 620578 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/c3/06490e98393dcb4d6ce2bf331a39335375c300afaef526897881fbeae6ab/narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad", size = 444952 }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799 }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552 }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566 }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482 }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376 }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137 }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414 }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397 }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499 }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257 }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775 }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272 }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573 }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782 }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038 }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480 }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036 }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643 }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117 }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584 }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450 }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491 }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830 }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927 }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557 }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253 }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552 }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075 }, +] + +[[package]] +name = "onnxruntime" +version = "1.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/69/6c40720201012c6af9aa7d4ecdd620e521bd806dc6269d636fdd5c5aeebe/onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2", size = 17332131 }, + { url = "https://files.pythonhosted.org/packages/38/e9/8c901c150ce0c368da38638f44152fb411059c0c7364b497c9e5c957321a/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7", size = 15152472 }, + { url = "https://files.pythonhosted.org/packages/d5/b6/7a4df417cdd01e8f067a509e123ac8b31af450a719fa7ed81787dd6057ec/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330", size = 17222993 }, + { url = "https://files.pythonhosted.org/packages/dd/59/8febe015f391aa1757fa5ba82c759ea4b6c14ef970132efb5e316665ba61/onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153", size = 12594863 }, + { url = "https://files.pythonhosted.org/packages/32/84/4155fcd362e8873eb6ce305acfeeadacd9e0e59415adac474bea3d9281bb/onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b", size = 12259895 }, + { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875 }, + { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485 }, + { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912 }, + { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856 }, + { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275 }, +] + +[[package]] +name = "openai" +version = "1.56.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/8f/9235cc14f16f24efb9ee7dd95f8cf1c4fb782d30e5b668f5f183de7735ad/openai-1.56.1.tar.gz", hash = "sha256:8b0449f22a0c318441eae8a8a789753c3b2cac86542be51ca45df788e26aa180", size = 315316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/31/22681ac17756f5274d978688640710572cdc53eae8e96a2d1572a50c7d40/openai-1.56.1-py3-none-any.whl", hash = "sha256:38e61183c2a98fedebbbb04a909a052d9f897358b070483fc0caff17300a227c", size = 389847 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790 }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831 }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267 }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281 }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453 }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361 }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702 }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846 }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618 }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212 }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693 }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002 }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971 }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722 }, +] + +[[package]] +name = "pillow" +version = "10.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265 }, + { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655 }, + { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304 }, + { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804 }, + { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126 }, + { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541 }, + { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616 }, + { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802 }, + { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213 }, + { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498 }, + { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219 }, + { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350 }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980 }, + { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799 }, + { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973 }, + { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054 }, + { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484 }, + { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375 }, + { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773 }, + { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690 }, + { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951 }, + { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427 }, +] + +[[package]] +name = "proglog" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/af/c108866c452eda1132f3d6b3cb6be2ae8430c97e9309f38ca9dbd430af37/proglog-0.1.12.tar.gz", hash = "sha256:361ee074721c277b89b75c061336cb8c5f287c92b043efa562ccf7866cda931c", size = 8794 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/1b/f7ea6cde25621cd9236541c66ff018f4268012a534ec31032bcb187dc5e7/proglog-0.1.12-py3-none-any.whl", hash = "sha256:ccaafce51e80a81c65dc907a460c07ccb8ec1f78dc660cfd8f9ec3a22f01b84c", size = 6337 }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208 }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777 }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647 }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929 }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778 }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144 }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030 }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252 }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064 }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429 }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727 }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097 }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084 }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637 }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064 }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061 }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037 }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324 }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505 }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242 }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474 }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575 }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736 }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019 }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376 }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988 }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615 }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066 }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655 }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789 }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, +] + +[[package]] +name = "proto-plus" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450 }, +] + +[[package]] +name = "protobuf" +version = "5.29.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357 }, + { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175 }, + { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619 }, + { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284 }, + { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478 }, + { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126 }, +] + +[[package]] +name = "pyarrow" +version = "23.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230 }, + { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050 }, + { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918 }, + { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811 }, + { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669 }, + { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698 }, + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575 }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540 }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940 }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063 }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045 }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741 }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678 }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627 }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362 }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625 }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534 }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853 }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465 }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414 }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441 }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291 }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632 }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905 }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 }, +] + +[[package]] +name = "pydeck" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403 }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/19/93bfb43a3c41b1dd0fa1fa66a08286f6467d36d30297a7aaab8c0b176a26/python_multipart-0.0.19.tar.gz", hash = "sha256:905502ef39050557b7a6af411f454bc19526529ca46ae6831508438890ce12cc", size = 36886 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/f4/ddd0fcdc454cf3870153ae16a818256523d31c3c8136e216bc6836ed4cd1/python_multipart-0.0.19-py3-none-any.whl", hash = "sha256:f8d5b0b9c618575bf9df01c684ded1d94a338839bdd8223838afacfb4bb2082d", size = 24448 }, +] + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, +] + +[[package]] +name = "redis" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/17/2f4a87ffa4cd93714cf52edfa3ea94589e9de65f71e9f99cbcfa84347a53/redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0", size = 4607878 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/f5/ffa560ecc4bafbf25f7961c3d6f50d627a90186352e27e7d0ba5b1f6d87d/redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897", size = 261428 }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/7a/617356cbecdb452812a5d42f720d6d5096b360d4a4c1073af700ea140ad2/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", size = 489415 }, + { url = "https://files.pythonhosted.org/packages/20/e6/bf057227144d02e3ba758b66649e87531d744dda5f3254f48660f18ae9d8/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", size = 291205 }, + { url = "https://files.pythonhosted.org/packages/eb/3b/637181b787dd1a820ba1c712cee2b4144cd84a32dc776ca067b12b2d70c8/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", size = 289225 }, + { url = "https://files.pythonhosted.org/packages/05/21/bac05d806ed02cd4b39d9c8e5b5f9a2998c94c3a351b7792e80671fa5315/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", size = 792434 }, + { url = "https://files.pythonhosted.org/packages/d9/17/c65d1d8ae90b772d5758eb4014e1e011bb2db353fc4455432e6cc9100df7/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", size = 861730 }, + { url = "https://files.pythonhosted.org/packages/ad/64/933321aa082a2c6ee2785f22776143ba89840189c20d3b6b1d12b6aae16b/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", size = 906495 }, + { url = "https://files.pythonhosted.org/packages/01/ea/4c8d306e9c36ac22417336b1e02e7b358152c34dc379673f2d331143725f/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", size = 799810 }, + { url = "https://files.pythonhosted.org/packages/29/ce/7605048f00e1379eba89d610c7d644d8f695dc9b26d3b6ecfa3132b872ff/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", size = 774242 }, + { url = "https://files.pythonhosted.org/packages/e9/77/283e0d5023fde22cd9e86190d6d9beb21590a452b195ffe00274de470691/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", size = 781257 }, + { url = "https://files.pythonhosted.org/packages/8b/fb/7f3b772be101373c8626ed34c5d727dcbb8abd42a7b1219bc25fd9a3cc04/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", size = 854490 }, + { url = "https://files.pythonhosted.org/packages/85/30/56547b80f34f4dd2986e1cdd63b1712932f63b6c4ce2f79c50a6cd79d1c2/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", size = 763544 }, + { url = "https://files.pythonhosted.org/packages/ac/2f/ce060fdfea8eff34a8997603532e44cdb7d1f35e3bc253612a8707a90538/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", size = 844442 }, + { url = "https://files.pythonhosted.org/packages/e5/44/810cb113096a1dacbe82789fbfab2823f79d19b7f1271acecb7009ba9b88/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", size = 789162 }, + { url = "https://files.pythonhosted.org/packages/20/96/9647dd7f2ecf6d9ce1fb04dfdb66910d094e10d8fe53e9c15096d8aa0bd2/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", size = 266227 }, + { url = "https://files.pythonhosted.org/packages/33/80/74e13262460530c3097ff343a17de9a34d040a5dc4de9cf3a8241faab51c/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", size = 278399 }, + { url = "https://files.pythonhosted.org/packages/1c/3c/39f19f47f19dcefa3403f09d13562ca1c0fd07ab54db2bc03148f3f6b46a/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", size = 270473 }, + { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434 }, + { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061 }, + { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628 }, + { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651 }, + { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916 }, + { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287 }, + { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126 }, + { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788 }, + { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184 }, + { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913 }, + { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732 }, + { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152 }, + { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076 }, + { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700 }, + { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768 }, + { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568 }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947 }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157 }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676 }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938 }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932 }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830 }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033 }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828 }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683 }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583 }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496 }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669 }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011 }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406 }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024 }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069 }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292 }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128 }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542 }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004 }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063 }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099 }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177 }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015 }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736 }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981 }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782 }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191 }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763 }, +] + +[[package]] +name = "starlette" +version = "0.41.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225 }, +] + +[[package]] +name = "streamlit" +version = "1.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "blinker" }, + { name = "cachetools" }, + { name = "click" }, + { name = "gitpython" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "tornado" }, + { name = "typing-extensions" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/52/83d46b4c5044477e9c6dee779704a9d4041610439533af9c0a5084921098/streamlit-1.45.0.tar.gz", hash = "sha256:4e99014e113a11a7163b9da5ac079efb1ae5f8575a09c5a6a9c43cd6877a2a88", size = 9462166 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ff/f41cfaf1bb58223fe77ff87213a689f6c9c82f7363f9d7c879d294dbe985/streamlit-1.45.0-py3-none-any.whl", hash = "sha256:b7d03ec68a23de0f1922ec9a28fbe3fe37d9fb31ad31d6c429d262c3631c2943", size = 9856265 }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814 }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565 }, + { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284 }, + { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201 }, + { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444 }, + { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080 }, + { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240 }, + { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422 }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728 }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049 }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008 }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665 }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230 }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688 }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694 }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275 }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472 }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736 }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835 }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673 }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818 }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195 }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982 }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245 }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069 }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263 }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429 }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363 }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786 }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133 }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, +] + +[[package]] +name = "tornado" +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983 }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246 }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229 }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192 }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039 }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445 }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582 }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990 }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016 }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488 }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, +] + +[[package]] +name = "uvicorn" +version = "0.32.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/3c/21dba3e7d76138725ef307e3d7ddd29b763119b3aa459d02cc05fefcff75/uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175", size = 77630 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/c1/2d27b0a15826c2b71dcf6e2f5402181ef85acf439617bb2f1453125ce1f3/uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e", size = 63828 }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641 }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248 }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988 }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566 }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079 }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741 }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099 }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678 }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803 }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163 }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859 }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202 }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866 }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852 }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919 }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602 }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461 }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336 }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737 }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029 }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310 }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587 }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528 }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339 }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061 }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132 }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289 }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950 }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960 }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703 }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325 }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067 }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285 }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359 }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674 }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879 }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288 }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378 }, +] diff --git a/webui.bat b/webui.bat new file mode 100644 index 0000000..cc20c76 --- /dev/null +++ b/webui.bat @@ -0,0 +1,52 @@ +@echo off +setlocal +set "CURRENT_DIR=%CD%" +echo ***** Current directory: %CURRENT_DIR% ***** +set "PYTHONPATH=%CURRENT_DIR%" + +rem set HF_ENDPOINT=https://hf-mirror.com + +if not defined MPT_WEBUI_HOST set "MPT_WEBUI_HOST=127.0.0.1" +if not defined MPT_WEBUI_PORT set "MPT_WEBUI_PORT=8501" + +set "STREAMLIT_CMD=" +if exist "%CURRENT_DIR%\.venv\Scripts\python.exe" ( + set "STREAMLIT_CMD="%CURRENT_DIR%\.venv\Scripts\python.exe" -m streamlit" +) else if exist "%CURRENT_DIR%\lib\python\python.exe" ( + set "STREAMLIT_CMD="%CURRENT_DIR%\lib\python\python.exe" -m streamlit" +) else ( + where uv >nul 2>nul + if not errorlevel 1 set "STREAMLIT_CMD=uv run streamlit" +) + +if not defined STREAMLIT_CMD ( + where streamlit >nul 2>nul + if not errorlevel 1 ( + echo ***** Warning: using streamlit from PATH. If dependencies fail, run 'uv sync --frozen' first. ***** + set "STREAMLIT_CMD=streamlit" + ) +) + +if not defined STREAMLIT_CMD ( + echo ***** Neither project Python, uv, nor streamlit was found. Please install dependencies first. ***** + pause + exit /b 1 +) + +set "SELECTED_WEBUI_PORT=" +for /f %%P in ('powershell -NoProfile -ExecutionPolicy Bypass -Command "$hostAddress=$null; foreach ($address in [Net.Dns]::GetHostAddresses($env:MPT_WEBUI_HOST)) { if ($address.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetwork) { $hostAddress=$address; break } }; if ($null -eq $hostAddress) { exit 1 }; $preferred=[int]$env:MPT_WEBUI_PORT; $candidates=New-Object System.Collections.Generic.List[int]; $candidates.Add($preferred); foreach ($candidate in 8502..8599) { if ($candidate -ne $preferred) { $candidates.Add($candidate) } }; foreach ($port in $candidates) { $socket=[Net.Sockets.Socket]::new([Net.Sockets.AddressFamily]::InterNetwork,[Net.Sockets.SocketType]::Stream,[Net.Sockets.ProtocolType]::Tcp); try { $socket.Bind([Net.IPEndPoint]::new($hostAddress,$port)); $socket.Close(); Write-Output $port; exit 0 } catch { try { $socket.Close() } catch {} } }; exit 1"') do set "SELECTED_WEBUI_PORT=%%P" + +if not defined SELECTED_WEBUI_PORT ( + echo ***** No available WebUI port found in 8501-8599 for %MPT_WEBUI_HOST%. ***** + echo ***** If Windows reports WinError 10013, check reserved ports: netsh interface ipv4 show excludedportrange protocol=tcp ***** + pause + exit /b 1 +) + +if not "%SELECTED_WEBUI_PORT%"=="%MPT_WEBUI_PORT%" ( + echo ***** Port %MPT_WEBUI_PORT% is unavailable, using %SELECTED_WEBUI_PORT% instead. ***** +) +set "MPT_WEBUI_PORT=%SELECTED_WEBUI_PORT%" + +echo ***** WebUI address: http://%MPT_WEBUI_HOST%:%MPT_WEBUI_PORT% ***** +%STREAMLIT_CMD% run .\webui\Main.py --server.address=%MPT_WEBUI_HOST% --server.port=%MPT_WEBUI_PORT% --browser.serverAddress=%MPT_WEBUI_HOST% --browser.gatherUsageStats=False --server.enableCORS=True diff --git a/webui.sh b/webui.sh new file mode 100644 index 0000000..fce0505 --- /dev/null +++ b/webui.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env sh + +# If you could not download the model from the official site, you can use the mirror site. +# Just remove the comment of the following line . +# 如果你无法从官方网站下载模型,你可以使用镜像网站。 +# 只需要移除下面一行的注释即可。 + +# export HF_ENDPOINT=https://hf-mirror.com + +CURRENT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export PYTHONPATH="$CURRENT_DIR${PYTHONPATH:+:$PYTHONPATH}" + +# 0.0.0.0 只能表示“监听所有网卡”,不适合作为浏览器访问地址。 +# macOS/Linux 下浏览器打开 http://0.0.0.0:8501 可能会经过代理或网关, +# 最终出现 502。默认绑定并打开 127.0.0.1,与 Windows 启动脚本保持一致。 +MPT_WEBUI_HOST="${MPT_WEBUI_HOST:-127.0.0.1}" +MPT_WEBUI_PORT="${MPT_WEBUI_PORT:-8501}" + +if [ -x "$CURRENT_DIR/.venv/bin/python" ]; then + PORT_CHECK_CMD="$CURRENT_DIR/.venv/bin/python" + set -- "$CURRENT_DIR/.venv/bin/python" -m streamlit +elif command -v uv >/dev/null 2>&1; then + PORT_CHECK_CMD="uv run python" + set -- uv run streamlit +elif command -v streamlit >/dev/null 2>&1; then + echo "***** Warning: using streamlit from PATH. If dependencies fail, run 'uv sync --frozen' first. *****" + PORT_CHECK_CMD="python3" + set -- streamlit +else + echo "***** Neither project Python, uv, nor streamlit was found. Please install dependencies first. *****" + exit 1 +fi + +find_available_port() { + WEBUI_HOST="$MPT_WEBUI_HOST" WEBUI_PORT="$MPT_WEBUI_PORT" "$@" - <<'PY' 2>/dev/null +import os +import socket +import sys + +host = os.environ.get("WEBUI_HOST", "127.0.0.1") +preferred = int(os.environ.get("WEBUI_PORT", "8501")) +candidates = [preferred] + [port for port in range(8502, 8600) if port != preferred] + +for port in candidates: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind((host, port)) + except OSError: + continue + print(port) + sys.exit(0) + +sys.exit(1) +PY +} + +# 用 Python 做端口探测,避免依赖 lsof/nc 在不同 macOS/Linux 发行版上的差异。 +# shellcheck disable=SC2086 +SELECTED_WEBUI_PORT=$(find_available_port $PORT_CHECK_CMD) + +if [ -z "$SELECTED_WEBUI_PORT" ]; then + echo "***** No available WebUI port found in 8501-8599 for $MPT_WEBUI_HOST. *****" + exit 1 +fi + +if [ "$SELECTED_WEBUI_PORT" != "$MPT_WEBUI_PORT" ]; then + echo "***** Port $MPT_WEBUI_PORT is unavailable, using $SELECTED_WEBUI_PORT instead. *****" +fi + +MPT_WEBUI_PORT="$SELECTED_WEBUI_PORT" + +echo "***** WebUI address: http://$MPT_WEBUI_HOST:$MPT_WEBUI_PORT *****" +"$@" run "$CURRENT_DIR/webui/Main.py" \ + --server.address="$MPT_WEBUI_HOST" \ + --server.port="$MPT_WEBUI_PORT" \ + --browser.serverAddress="$MPT_WEBUI_HOST" \ + --browser.gatherUsageStats=False \ + --server.enableCORS=True diff --git a/webui/Main.py b/webui/Main.py new file mode 100644 index 0000000..8249261 --- /dev/null +++ b/webui/Main.py @@ -0,0 +1,1257 @@ +import os +import sys +import webbrowser +from uuid import UUID, uuid4 + +import streamlit as st +from loguru import logger + +# Add the root directory of the project to the system path to allow importing modules from the project +root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +if root_dir not in sys.path: + sys.path.append(root_dir) + print("******** sys.path ********") + print(sys.path) + print("") + +from app.config import config +from app.models.schema import ( + MaterialInfo, + VideoAspect, + VideoConcatMode, + VideoParams, + VideoTransitionMode, +) +from app.services import llm, voice +from app.services import task as tm +from app.utils import utils + +st.set_page_config( + page_title="MoneyPrinterTurbo", + page_icon="🤖", + layout="wide", + initial_sidebar_state="auto", + menu_items={ + "Report a bug": "https://github.com/harry0703/MoneyPrinterTurbo/issues", + "About": "# MoneyPrinterTurbo\nSimply provide a topic or keyword for a video, and it will " + "automatically generate the video copy, video materials, video subtitles, " + "and video background music before synthesizing a high-definition short " + "video.\n\nhttps://github.com/harry0703/MoneyPrinterTurbo", + }, +) + + +streamlit_style = """ + +""" +st.markdown(streamlit_style, unsafe_allow_html=True) + +# 定义资源目录 +font_dir = os.path.join(root_dir, "resource", "fonts") +song_dir = os.path.join(root_dir, "resource", "songs") +i18n_dir = os.path.join(root_dir, "webui", "i18n") +config_file = os.path.join(root_dir, "webui", ".streamlit", "webui.toml") +system_locale = utils.get_system_locale() + + +if "video_subject" not in st.session_state: + st.session_state["video_subject"] = "" +if "video_script" not in st.session_state: + st.session_state["video_script"] = "" +if "video_terms" not in st.session_state: + st.session_state["video_terms"] = "" +if "video_script_prompt" not in st.session_state: + st.session_state["video_script_prompt"] = "" +if "custom_system_prompt" not in st.session_state: + st.session_state["custom_system_prompt"] = llm.DEFAULT_SCRIPT_SYSTEM_PROMPT +if "use_custom_system_prompt" not in st.session_state: + st.session_state["use_custom_system_prompt"] = False +if "ui_language" not in st.session_state: + st.session_state["ui_language"] = config.ui.get("language", system_locale) +if "local_video_materials" not in st.session_state: + # 记住用户最近一次已经落盘的本地素材,避免仅修改文案后二次生成时丢失素材列表。 + st.session_state["local_video_materials"] = [] + +# 加载语言文件 +locales = utils.load_locales(i18n_dir) + +# 创建一个顶部栏,包含标题和语言选择 +title_col, lang_col = st.columns([3, 1]) + +with title_col: + st.title(f"MoneyPrinterTurbo v{config.project_version}") + +with lang_col: + display_languages = [] + selected_index = 0 + for i, code in enumerate(locales.keys()): + display_languages.append(f"{code} - {locales[code].get('Language')}") + if code == st.session_state.get("ui_language", ""): + selected_index = i + + selected_language = st.selectbox( + "Language / 语言", + options=display_languages, + index=selected_index, + key="top_language_selector", + label_visibility="collapsed", + ) + if selected_language: + code = selected_language.split(" - ")[0].strip() + st.session_state["ui_language"] = code + config.ui["language"] = code + +support_locales = [ + "zh-CN", + "zh-HK", + "zh-TW", + "de-DE", + "en-US", + "fr-FR", + "vi-VN", + "th-TH", + "tr-TR", +] + + +def get_all_fonts(): + fonts = [] + for root, dirs, files in os.walk(font_dir): + for file in files: + if file.endswith(".ttf") or file.endswith(".ttc"): + fonts.append(file) + fonts.sort() + return fonts + + +def get_all_songs(): + songs = [] + for root, dirs, files in os.walk(song_dir): + for file in files: + if file.endswith(".mp3"): + songs.append(file) + return songs + + +def open_task_folder(task_id): + try: + # task_id 应始终是服务端生成的 UUID。这里先做格式校验,避免异常值 + # 通过路径拼接访问任务目录之外的位置,也避免后续打开目录时触发 + # 平台 shell 对特殊字符的解释。 + normalized_task_id = str(UUID(str(task_id))) + tasks_root = os.path.abspath(os.path.join(root_dir, "storage", "tasks")) + path = os.path.abspath(os.path.join(tasks_root, normalized_task_id)) + + # 即使 UUID 校验通过,也再次确认最终路径仍在任务根目录内,避免 + # 未来调用方调整 task_id 来源时引入路径穿越风险。 + if not path.startswith(tasks_root + os.sep): + logger.warning(f"invalid task folder path: {path}") + return + + if os.path.isdir(path): + webbrowser.open(f"file://{path}") + except Exception as e: + logger.error(e) + + +def scroll_to_bottom(): + js = """ + + """ + st.components.v1.html(js, height=0, width=0) + + +def init_log(): + logger.remove() + _lvl = "DEBUG" + + def format_record(record): + # 获取日志记录中的文件全路径 + file_path = record["file"].path + # 将绝对路径转换为相对于项目根目录的路径 + relative_path = os.path.relpath(file_path, root_dir) + # 更新记录中的文件路径 + record["file"].path = f"./{relative_path}" + # 返回修改后的格式字符串 + # 您可以根据需要调整这里的格式 + record["message"] = record["message"].replace(root_dir, ".") + + _format = ( + "{time:%Y-%m-%d %H:%M:%S} | " + + "{level} | " + + '"{file.path}:{line}": {function} ' + + "- {message}" + + "\n" + ) + return _format + + logger.add( + sys.stdout, + level=_lvl, + format=format_record, + colorize=True, + ) + + +init_log() + +locales = utils.load_locales(i18n_dir) + + +def tr(key): + loc = locales.get(st.session_state["ui_language"], {}) + return loc.get("Translation", {}).get(key, key) + + +# 创建基础设置折叠框 +if not config.app.get("hide_config", False): + with st.expander(tr("Basic Settings"), expanded=False): + config_panels = st.columns(3) + left_config_panel = config_panels[0] + middle_config_panel = config_panels[1] + right_config_panel = config_panels[2] + + # 左侧面板 - 日志设置 + with left_config_panel: + # 是否隐藏配置面板 + hide_config = st.checkbox( + tr("Hide Basic Settings"), value=config.app.get("hide_config", False) + ) + config.app["hide_config"] = hide_config + + # 是否禁用日志显示 + hide_log = st.checkbox( + tr("Hide Log"), value=config.ui.get("hide_log", False) + ) + config.ui["hide_log"] = hide_log + + # 中间面板 - LLM 设置 + + with middle_config_panel: + st.write(tr("LLM Settings")) + llm_providers = [ + "OpenAI", + "Moonshot", + "Azure", + "Qwen", + "DeepSeek", + "ModelScope", + "Gemini", + "Grok", + "Ollama", + "G4f", + "OneAPI", + "Cloudflare", + "ERNIE", + "MiMo", + "Pollinations", + "LiteLLM", + ] + saved_llm_provider = config.app.get("llm_provider", "OpenAI").lower() + saved_llm_provider_index = 0 + for i, provider in enumerate(llm_providers): + if provider.lower() == saved_llm_provider: + saved_llm_provider_index = i + break + + llm_provider = st.selectbox( + tr("LLM Provider"), + options=llm_providers, + index=saved_llm_provider_index, + ) + llm_helper = st.container() + llm_provider = llm_provider.lower() + config.app["llm_provider"] = llm_provider + + llm_api_key = config.app.get(f"{llm_provider}_api_key", "") + llm_secret_key = config.app.get( + f"{llm_provider}_secret_key", "" + ) # only for baidu ernie + llm_base_url = config.app.get(f"{llm_provider}_base_url", "") + llm_model_name = config.app.get(f"{llm_provider}_model_name", "") + llm_account_id = config.app.get(f"{llm_provider}_account_id", "") + + tips = "" + if llm_provider == "ollama": + if not llm_model_name: + llm_model_name = "qwen:7b" + if not llm_base_url: + llm_base_url = config.get_default_ollama_base_url() + + with llm_helper: + docker_hint = "" + if config.is_running_in_container(): + docker_hint = "\n > 检测到容器环境,未配置 Base Url 时会默认使用 `http://host.docker.internal:11434/v1`\n" + tips = f""" + ##### Ollama配置说明 + - **API Key**: 随便填写,比如 123 + - **Base Url**: 一般为 http://localhost:11434/v1 + - 如果 `MoneyPrinterTurbo` 和 `Ollama` **不在同一台机器上**,需要填写 `Ollama` 机器的IP地址 + - 如果 `MoneyPrinterTurbo` 是 `Docker` 部署,建议填写 `http://host.docker.internal:11434/v1`{docker_hint} + - **Model Name**: 使用 `ollama list` 查看,比如 `qwen:7b` + """ + + if llm_provider == "openai": + if not llm_model_name: + llm_model_name = "gpt-3.5-turbo" + with llm_helper: + tips = """ + ##### OpenAI 配置说明 + > 需要VPN开启全局流量模式 + - **API Key**: [点击到官网申请](https://platform.openai.com/api-keys) + - **Base Url**: 官方 OpenAI 可留空;如果使用 OpenAI 兼容供应商(例如 OpenRouter),请填写对应的兼容接口地址 + - **Model Name**: 填写**有权限**的模型;如果使用兼容供应商,请填写该平台支持的模型 ID + """ + + if llm_provider == "moonshot": + if not llm_model_name: + llm_model_name = "moonshot-v1-8k" + with llm_helper: + tips = """ + ##### Moonshot 配置说明 + - **API Key**: [点击到官网申请](https://platform.moonshot.cn/console/api-keys) + - **Base Url**: 固定为 https://api.moonshot.cn/v1 + - **Model Name**: 比如 moonshot-v1-8k,[点击查看模型列表](https://platform.moonshot.cn/docs/intro#%E6%A8%A1%E5%9E%8B%E5%88%97%E8%A1%A8) + """ + if llm_provider == "oneapi": + if not llm_model_name: + llm_model_name = ( + "claude-3-5-sonnet-20240620" # 默认模型,可以根据需要调整 + ) + with llm_helper: + tips = """ + ##### OneAPI 配置说明 + - **API Key**: 填写您的 OneAPI 密钥 + - **Base Url**: 填写 OneAPI 的基础 URL + - **Model Name**: 填写您要使用的模型名称,例如 claude-3-5-sonnet-20240620 + """ + + if llm_provider == "qwen": + if not llm_model_name: + llm_model_name = "qwen-max" + with llm_helper: + tips = """ + ##### 通义千问Qwen 配置说明 + - **API Key**: [点击到官网申请](https://dashscope.console.aliyun.com/apiKey) + - **Base Url**: 留空 + - **Model Name**: 比如 qwen-max,[点击查看模型列表](https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction#3ef6d0bcf91wy) + """ + + if llm_provider == "g4f": + if not llm_model_name: + llm_model_name = "gpt-3.5-turbo" + with llm_helper: + tips = """ + ##### gpt4free 配置说明 + > [GitHub开源项目](https://github.com/xtekky/gpt4free),可以免费使用GPT模型,但是**稳定性较差** + - **API Key**: 随便填写,比如 123 + - **Base Url**: 留空 + - **Model Name**: 比如 gpt-3.5-turbo,[点击查看模型列表](https://github.com/xtekky/gpt4free/blob/main/g4f/models.py#L308) + """ + if llm_provider == "azure": + with llm_helper: + tips = """ + ##### Azure 配置说明 + > [点击查看如何部署模型](https://learn.microsoft.com/zh-cn/azure/ai-services/openai/how-to/create-resource) + - **API Key**: [点击到Azure后台创建](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI) + - **Base Url**: 留空 + - **Model Name**: 填写你实际的部署名 + """ + + if llm_provider == "gemini": + if not llm_model_name: + llm_model_name = "gemini-1.0-pro" + + with llm_helper: + tips = """ + ##### Gemini 配置说明 + > 需要VPN开启全局流量模式 + - **API Key**: [点击到官网申请](https://ai.google.dev/) + - **Base Url**: 留空 + - **Model Name**: 比如 gemini-1.0-pro + """ + + if llm_provider == "grok": + if not llm_model_name: + llm_model_name = "grok-4.3" + if not llm_base_url: + llm_base_url = "https://api.x.ai/v1" + + with llm_helper: + tips = """ + ##### Grok 配置说明 + - **API Key**: 填写您的 GrokAPI 密钥 + - **Base Url**: 填写 GrokAPI 的基础 URL + - **Model Name**: 比如 grok-4.3 + """ + + if llm_provider == "deepseek": + if not llm_model_name: + llm_model_name = "deepseek-chat" + if not llm_base_url: + llm_base_url = "https://api.deepseek.com" + with llm_helper: + tips = """ + ##### DeepSeek 配置说明 + - **API Key**: [点击到官网申请](https://platform.deepseek.com/api_keys) + - **Base Url**: 固定为 https://api.deepseek.com + - **Model Name**: 固定为 deepseek-chat + """ + + if llm_provider == "mimo": + if not llm_model_name: + llm_model_name = "mimo-v2.5-pro" + if not llm_base_url: + llm_base_url = "https://api.xiaomimimo.com/v1" + with llm_helper: + tips = """ + ##### Xiaomi MiMo 配置说明 + - **API Key**: [点击到官网申请](https://platform.xiaomimimo.com/docs/zh-CN/quick-start/first-api-call) + - **Base Url**: 固定为 https://api.xiaomimimo.com/v1 + - **Model Name**: 默认 mimo-v2.5-pro,也可以按官方文档填写其它可用模型 + """ + + if llm_provider == "modelscope": + if not llm_model_name: + llm_model_name = "Qwen/Qwen3-32B" + if not llm_base_url: + llm_base_url = "https://api-inference.modelscope.cn/v1/" + with llm_helper: + tips = """ + ##### ModelScope 配置说明 + - **API Key**: [点击到官网申请](https://modelscope.cn/docs/model-service/API-Inference/intro) + - **Base Url**: 固定为 https://api-inference.modelscope.cn/v1/ + - **Model Name**: 比如 Qwen/Qwen3-32B,[点击查看模型列表](https://modelscope.cn/models?filter=inference_type&page=1) + """ + + if llm_provider == "ernie": + with llm_helper: + tips = """ + ##### 百度文心一言 配置说明 + - **API Key**: [点击到官网申请](https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application) + - **Secret Key**: [点击到官网申请](https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application) + - **Base Url**: 填写 **请求地址** [点击查看文档](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/jlil56u11#%E8%AF%B7%E6%B1%82%E8%AF%B4%E6%98%8E) + """ + + if llm_provider == "pollinations": + if not llm_model_name: + llm_model_name = "default" + with llm_helper: + tips = """ + ##### Pollinations AI Configuration + - **API Key**: Optional - Leave empty for public access + - **Base Url**: Default is https://text.pollinations.ai/openai + - **Model Name**: Use 'openai-fast' or specify a model name + """ + + if llm_provider == "litellm": + if not llm_model_name: + llm_model_name = "openai/gpt-4o-mini" + with llm_helper: + tips = """ + ##### LiteLLM Configuration + > [LiteLLM](https://github.com/BerriAI/litellm) routes to 100+ LLM providers via a unified interface. + > Set your provider's API key as an env var: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `AWS_ACCESS_KEY_ID`, etc. + - **Model Name**: LiteLLM format — `openai/gpt-4o`, `anthropic/claude-sonnet-4-20250514`, `bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0`, `gemini/gemini-2.5-flash`. See [full provider list](https://docs.litellm.ai/docs/providers) + """ + + if tips and config.ui["language"] == "zh": + st.warning( + "中国用户建议使用 **DeepSeek** 或 **Moonshot** 作为大模型提供商\n- 国内可直接访问,不需要VPN \n- 注册就送额度,基本够用" + ) + st.info(tips) + + st_llm_api_key = st.text_input( + tr("API Key"), value=llm_api_key, type="password" + ) + st_llm_base_url = st.text_input(tr("Base Url"), value=llm_base_url) + st_llm_model_name = "" + if llm_provider != "ernie": + st_llm_model_name = st.text_input( + tr("Model Name"), + value=llm_model_name, + key=f"{llm_provider}_model_name_input", + ) + if st_llm_model_name: + config.app[f"{llm_provider}_model_name"] = st_llm_model_name + else: + st_llm_model_name = None + + if st_llm_api_key: + config.app[f"{llm_provider}_api_key"] = st_llm_api_key + if st_llm_base_url: + config.app[f"{llm_provider}_base_url"] = st_llm_base_url + if st_llm_model_name: + config.app[f"{llm_provider}_model_name"] = st_llm_model_name + if llm_provider == "ernie": + st_llm_secret_key = st.text_input( + tr("Secret Key"), value=llm_secret_key, type="password" + ) + config.app[f"{llm_provider}_secret_key"] = st_llm_secret_key + + if llm_provider == "cloudflare": + st_llm_account_id = st.text_input( + tr("Account ID"), value=llm_account_id + ) + if st_llm_account_id: + config.app[f"{llm_provider}_account_id"] = st_llm_account_id + + # 右侧面板 - API 密钥设置 + with right_config_panel: + + def get_keys_from_config(cfg_key): + api_keys = config.app.get(cfg_key, []) + if isinstance(api_keys, str): + api_keys = [api_keys] + api_key = ", ".join(api_keys) + return api_key + + def save_keys_to_config(cfg_key, value): + value = value.replace(" ", "") + if value: + config.app[cfg_key] = value.split(",") + + st.write(tr("Video Source Settings")) + + pexels_api_key = get_keys_from_config("pexels_api_keys") + pexels_api_key = st.text_input( + tr("Pexels API Key"), value=pexels_api_key, type="password" + ) + save_keys_to_config("pexels_api_keys", pexels_api_key) + + pixabay_api_key = get_keys_from_config("pixabay_api_keys") + pixabay_api_key = st.text_input( + tr("Pixabay API Key"), value=pixabay_api_key, type="password" + ) + save_keys_to_config("pixabay_api_keys", pixabay_api_key) + +llm_provider = config.app.get("llm_provider", "").lower() +panel = st.columns(3) +left_panel = panel[0] +middle_panel = panel[1] +right_panel = panel[2] + +params = VideoParams(video_subject="") +uploaded_files = [] +uploaded_audio_file = None + +with left_panel: + with st.container(border=True): + st.write(tr("Video Script Settings")) + params.video_subject = st.text_input( + tr("Video Subject"), + key="video_subject", + ).strip() + + video_languages = [ + (tr("Auto Detect"), ""), + ] + for code in support_locales: + video_languages.append((code, code)) + + selected_index = st.selectbox( + tr("Script Language"), + index=0, + options=range( + len(video_languages) + ), # Use the index as the internal option value + format_func=lambda x: video_languages[x][ + 0 + ], # The label is displayed to the user + ) + params.video_language = video_languages[selected_index][1] + + with st.expander(tr("Advanced Script Settings"), expanded=False): + params.paragraph_number = st.slider( + tr("Script Paragraph Number"), + min_value=llm.MIN_SCRIPT_PARAGRAPH_NUMBER, + max_value=llm.MAX_SCRIPT_PARAGRAPH_NUMBER, + value=st.session_state.get("paragraph_number_input", 1), + key="paragraph_number_input", + ) + params.video_script_prompt = st.text_area( + tr("Custom Script Requirements"), + height=100, + max_chars=llm.MAX_SCRIPT_PROMPT_LENGTH, + placeholder=tr("Custom Script Requirements Placeholder"), + key="video_script_prompt", + ).strip() + + use_custom_system_prompt = st.checkbox( + tr("Use Custom System Prompt"), + help=tr("Use Custom System Prompt Help"), + key="use_custom_system_prompt", + ) + + if use_custom_system_prompt: + custom_system_prompt = st.text_area( + tr("Custom System Prompt"), + height=240, + max_chars=llm.MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, + key="custom_system_prompt", + ).strip() + params.custom_system_prompt = custom_system_prompt + else: + params.custom_system_prompt = "" + + if st.button( + tr("Generate Video Script and Keywords"), key="auto_generate_script" + ): + with st.spinner(tr("Generating Video Script and Keywords")): + script = llm.generate_script( + video_subject=params.video_subject, + language=params.video_language, + paragraph_number=params.paragraph_number, + video_script_prompt=params.video_script_prompt, + custom_system_prompt=params.custom_system_prompt, + ) + terms = llm.generate_terms(params.video_subject, script) + if "Error: " in script: + st.error(tr(script)) + elif "Error: " in terms: + st.error(tr(terms)) + else: + st.session_state["video_script"] = script + st.session_state["video_terms"] = ", ".join(terms) + params.video_script = st.text_area( + tr("Video Script"), value=st.session_state["video_script"], height=280 + ) + if st.button(tr("Generate Video Keywords"), key="auto_generate_terms"): + if not params.video_script: + st.error(tr("Please Enter the Video Subject")) + st.stop() + + with st.spinner(tr("Generating Video Keywords")): + terms = llm.generate_terms(params.video_subject, params.video_script) + if "Error: " in terms: + st.error(tr(terms)) + else: + st.session_state["video_terms"] = ", ".join(terms) + + params.video_terms = st.text_area( + tr("Video Keywords"), value=st.session_state["video_terms"] + ) + +with middle_panel: + with st.container(border=True): + st.write(tr("Video Settings")) + video_concat_modes = [ + (tr("Sequential"), "sequential"), + (tr("Random"), "random"), + ] + video_sources = [ + (tr("Pexels"), "pexels"), + (tr("Pixabay"), "pixabay"), + (tr("Local file"), "local"), + (tr("TikTok"), "douyin"), + (tr("Bilibili"), "bilibili"), + (tr("Xiaohongshu"), "xiaohongshu"), + ] + + saved_video_source_name = config.app.get("video_source", "pexels") + saved_video_source_index = [v[1] for v in video_sources].index( + saved_video_source_name + ) + + selected_index = st.selectbox( + tr("Video Source"), + options=range(len(video_sources)), + format_func=lambda x: video_sources[x][0], + index=saved_video_source_index, + ) + params.video_source = video_sources[selected_index][1] + config.app["video_source"] = params.video_source + + if params.video_source == "local": + # Streamlit 的文件类型校验对扩展名大小写敏感,这里同时放行大小写两种形式。 + local_file_types = ["mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png"] + uploaded_files = st.file_uploader( + "Upload Local Files", + type=local_file_types + [file_type.upper() for file_type in local_file_types], + accept_multiple_files=True, + ) + + selected_index = st.selectbox( + tr("Video Concat Mode"), + index=1, + options=range( + len(video_concat_modes) + ), # Use the index as the internal option value + format_func=lambda x: video_concat_modes[x][ + 0 + ], # The label is displayed to the user + ) + params.video_concat_mode = VideoConcatMode( + video_concat_modes[selected_index][1] + ) + + # 视频转场模式 + video_transition_modes = [ + (tr("None"), VideoTransitionMode.none.value), + (tr("Shuffle"), VideoTransitionMode.shuffle.value), + (tr("FadeIn"), VideoTransitionMode.fade_in.value), + (tr("FadeOut"), VideoTransitionMode.fade_out.value), + (tr("SlideIn"), VideoTransitionMode.slide_in.value), + (tr("SlideOut"), VideoTransitionMode.slide_out.value), + ] + selected_index = st.selectbox( + tr("Video Transition Mode"), + options=range(len(video_transition_modes)), + format_func=lambda x: video_transition_modes[x][0], + index=0, + ) + params.video_transition_mode = VideoTransitionMode( + video_transition_modes[selected_index][1] + ) + + video_aspect_ratios = [ + (tr("Portrait"), VideoAspect.portrait.value), + (tr("Landscape"), VideoAspect.landscape.value), + ] + selected_index = st.selectbox( + tr("Video Ratio"), + options=range( + len(video_aspect_ratios) + ), # Use the index as the internal option value + format_func=lambda x: video_aspect_ratios[x][ + 0 + ], # The label is displayed to the user + ) + params.video_aspect = VideoAspect(video_aspect_ratios[selected_index][1]) + + params.video_clip_duration = st.selectbox( + tr("Clip Duration"), options=[2, 3, 4, 5, 6, 7, 8, 9, 10], index=1 + ) + params.video_count = st.selectbox( + tr("Number of Videos Generated Simultaneously"), + options=[1, 2, 3, 4, 5], + index=0, + ) + with st.container(border=True): + st.write(tr("Audio Settings")) + + # 添加TTS服务器选择下拉框 + tts_servers = [ + ("azure-tts-v1", "Azure TTS V1"), + ("azure-tts-v2", "Azure TTS V2"), + ("siliconflow", "SiliconFlow TTS"), + ("gemini-tts", "Google Gemini TTS"), + ("mimo-tts", "Xiaomi MiMo TTS"), + ] + + # 获取保存的TTS服务器,默认为v1 + saved_tts_server = config.ui.get("tts_server", "azure-tts-v1") + saved_tts_server_index = 0 + for i, (server_value, _) in enumerate(tts_servers): + if server_value == saved_tts_server: + saved_tts_server_index = i + break + + selected_tts_server_index = st.selectbox( + tr("TTS Servers"), + options=range(len(tts_servers)), + format_func=lambda x: tts_servers[x][1], + index=saved_tts_server_index, + ) + + selected_tts_server = tts_servers[selected_tts_server_index][0] + config.ui["tts_server"] = selected_tts_server + + # 根据选择的TTS服务器获取声音列表 + filtered_voices = [] + + if selected_tts_server == "siliconflow": + # 获取硅基流动的声音列表 + filtered_voices = voice.get_siliconflow_voices() + elif selected_tts_server == "gemini-tts": + # 获取Gemini TTS的声音列表 + filtered_voices = voice.get_gemini_voices() + elif selected_tts_server == "mimo-tts": + # 获取 Xiaomi MiMo TTS 的预置音色列表 + filtered_voices = voice.get_mimo_voices() + else: + # 获取Azure的声音列表 + all_voices = voice.get_all_azure_voices(filter_locals=None) + + # 根据选择的TTS服务器筛选声音 + for v in all_voices: + if selected_tts_server == "azure-tts-v2": + # V2版本的声音名称中包含"v2" + if "V2" in v: + filtered_voices.append(v) + else: + # V1版本的声音名称中不包含"v2" + if "V2" not in v: + filtered_voices.append(v) + + friendly_names = { + v: v.replace("Female", tr("Female")) + .replace("Male", tr("Male")) + .replace("Neural", "") + for v in filtered_voices + } + + saved_voice_name = config.ui.get("voice_name", "") + saved_voice_name_index = 0 + + # 检查保存的声音是否在当前筛选的声音列表中 + if saved_voice_name in friendly_names: + saved_voice_name_index = list(friendly_names.keys()).index(saved_voice_name) + else: + # 如果不在,则根据当前UI语言选择一个默认声音 + for i, v in enumerate(filtered_voices): + if v.lower().startswith(st.session_state["ui_language"].lower()): + saved_voice_name_index = i + break + + # 如果没有找到匹配的声音,使用第一个声音 + if saved_voice_name_index >= len(friendly_names) and friendly_names: + saved_voice_name_index = 0 + + # 确保有声音可选 + if friendly_names: + selected_friendly_name = st.selectbox( + tr("Speech Synthesis"), + options=list(friendly_names.values()), + index=min(saved_voice_name_index, len(friendly_names) - 1) + if friendly_names + else 0, + ) + + voice_name = list(friendly_names.keys())[ + list(friendly_names.values()).index(selected_friendly_name) + ] + params.voice_name = voice_name + config.ui["voice_name"] = voice_name + else: + # 如果没有声音可选,显示提示信息 + st.warning( + tr( + "No voices available for the selected TTS server. Please select another server." + ) + ) + params.voice_name = "" + config.ui["voice_name"] = "" + + # 只有在有声音可选时才显示试听按钮 + if friendly_names and st.button(tr("Play Voice")): + play_content = params.video_subject + if not play_content: + play_content = params.video_script + if not play_content: + play_content = tr("Voice Example") + with st.spinner(tr("Synthesizing Voice")): + temp_dir = utils.storage_dir("temp", create=True) + audio_file = os.path.join(temp_dir, f"tmp-voice-{str(uuid4())}.mp3") + sub_maker = voice.tts( + text=play_content, + voice_name=voice_name, + voice_rate=params.voice_rate, + voice_file=audio_file, + voice_volume=params.voice_volume, + ) + # if the voice file generation failed, try again with a default content. + if not sub_maker: + play_content = "This is a example voice. if you hear this, the voice synthesis failed with the original content." + sub_maker = voice.tts( + text=play_content, + voice_name=voice_name, + voice_rate=params.voice_rate, + voice_file=audio_file, + voice_volume=params.voice_volume, + ) + + if sub_maker and os.path.exists(audio_file): + st.audio(audio_file, format="audio/mp3") + if os.path.exists(audio_file): + os.remove(audio_file) + + # 当选择V2版本或者声音是V2声音时,显示服务区域和API key输入框 + if selected_tts_server == "azure-tts-v2" or ( + voice_name and voice.is_azure_v2_voice(voice_name) + ): + saved_azure_speech_region = config.azure.get("speech_region", "") + saved_azure_speech_key = config.azure.get("speech_key", "") + azure_speech_region = st.text_input( + tr("Speech Region"), + value=saved_azure_speech_region, + key="azure_speech_region_input", + ) + azure_speech_key = st.text_input( + tr("Speech Key"), + value=saved_azure_speech_key, + type="password", + key="azure_speech_key_input", + ) + config.azure["speech_region"] = azure_speech_region + config.azure["speech_key"] = azure_speech_key + + # 当选择硅基流动时,显示API key输入框和说明信息 + if selected_tts_server == "siliconflow" or ( + voice_name and voice.is_siliconflow_voice(voice_name) + ): + saved_siliconflow_api_key = config.siliconflow.get("api_key", "") + + siliconflow_api_key = st.text_input( + tr("SiliconFlow API Key"), + value=saved_siliconflow_api_key, + type="password", + key="siliconflow_api_key_input", + ) + + # 显示硅基流动的说明信息 + st.info( + tr("SiliconFlow TTS Settings") + + ":\n" + + "- " + + tr("Speed: Range [0.25, 4.0], default is 1.0") + + "\n" + + "- " + + tr("Volume: Uses Speech Volume setting, default 1.0 maps to gain 0") + ) + + config.siliconflow["api_key"] = siliconflow_api_key + + # 当选择 Xiaomi MiMo TTS 时,复用 MiMo LLM provider 的 API Key。 + # 这样用户如果同时使用 MiMo 生成文案和语音,只需要维护一份密钥。 + if selected_tts_server == "mimo-tts" or ( + voice_name and voice.is_mimo_voice(voice_name) + ): + saved_mimo_api_key = config.app.get("mimo_api_key", "") + + mimo_api_key = st.text_input( + tr("MiMo API Key"), + value=saved_mimo_api_key, + type="password", + key="mimo_tts_api_key_input", + ) + + st.info( + tr("MiMo TTS Settings") + + ":\n" + + "- " + + tr("Uses Xiaomi MiMo V2.5 TTS preset voices") + + "\n" + + "- " + + tr("Speed and volume are currently handled by the provider defaults") + ) + + config.app["mimo_api_key"] = mimo_api_key + + params.voice_volume = st.selectbox( + tr("Speech Volume"), + options=[0.6, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0, 4.0, 5.0], + index=2, + ) + + params.voice_rate = st.selectbox( + tr("Speech Rate"), + options=[0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 1.8, 2.0], + index=2, + ) + + custom_audio_file_types = ["mp3", "wav", "m4a", "aac", "flac", "ogg"] + uploaded_audio_file = st.file_uploader( + tr("Custom Audio File"), + type=custom_audio_file_types + + [file_type.upper() for file_type in custom_audio_file_types], + accept_multiple_files=False, + key="custom_audio_file_uploader", + ) + if uploaded_audio_file: + st.audio(uploaded_audio_file, format="audio/mp3") + st.info( + tr( + "Custom audio will be used directly. TTS synthesis will be skipped for this task." + ) + ) + + bgm_options = [ + (tr("No Background Music"), ""), + (tr("Random Background Music"), "random"), + (tr("Custom Background Music"), "custom"), + ] + selected_index = st.selectbox( + tr("Background Music"), + index=1, + options=range( + len(bgm_options) + ), # Use the index as the internal option value + format_func=lambda x: bgm_options[x][ + 0 + ], # The label is displayed to the user + ) + # Get the selected background music type + params.bgm_type = bgm_options[selected_index][1] + + # Show or hide components based on the selection + if params.bgm_type == "custom": + custom_bgm_file = st.text_input( + tr("Custom Background Music File"), key="custom_bgm_file_input" + ) + if custom_bgm_file: + # 这里不直接用 os.path.exists 判断,因为用户常见输入是 + # output000.mp3,这个文件名需要由服务层映射到 resource/songs + # 目录后再校验。服务层会统一限制目录和文件类型,避免任意路径读取。 + params.bgm_file = custom_bgm_file.strip() + # st.write(f":red[已选择自定义背景音乐]:**{custom_bgm_file}**") + params.bgm_volume = st.selectbox( + tr("Background Music Volume"), + options=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], + index=2, + ) + +with right_panel: + with st.container(border=True): + st.write(tr("Subtitle Settings")) + params.subtitle_enabled = st.checkbox(tr("Enable Subtitles"), value=True) + font_names = get_all_fonts() + saved_font_name = config.ui.get("font_name", "MicrosoftYaHeiBold.ttc") + saved_font_name_index = 0 + if saved_font_name in font_names: + saved_font_name_index = font_names.index(saved_font_name) + params.font_name = st.selectbox( + tr("Font"), font_names, index=saved_font_name_index + ) + config.ui["font_name"] = params.font_name + + subtitle_positions = [ + (tr("Top"), "top"), + (tr("Center"), "center"), + (tr("Bottom"), "bottom"), + (tr("Custom"), "custom"), + ] + saved_subtitle_position = config.ui.get("subtitle_position", "bottom") + saved_position_index = 2 + for i, (_, pos_value) in enumerate(subtitle_positions): + if pos_value == saved_subtitle_position: + saved_position_index = i + break + selected_index = st.selectbox( + tr("Position"), + index=saved_position_index, + options=range(len(subtitle_positions)), + format_func=lambda x: subtitle_positions[x][0], + ) + params.subtitle_position = subtitle_positions[selected_index][1] + config.ui["subtitle_position"] = params.subtitle_position + + if params.subtitle_position == "custom": + saved_custom_position = config.ui.get("custom_position", 70.0) + custom_position = st.text_input( + tr("Custom Position (% from top)"), + value=str(saved_custom_position), + key="custom_position_input", + ) + try: + params.custom_position = float(custom_position) + if params.custom_position < 0 or params.custom_position > 100: + st.error(tr("Please enter a value between 0 and 100")) + else: + config.ui["custom_position"] = params.custom_position + except ValueError: + st.error(tr("Please enter a valid number")) + + font_cols = st.columns([0.3, 0.7]) + with font_cols[0]: + saved_text_fore_color = config.ui.get("text_fore_color", "#FFFFFF") + params.text_fore_color = st.color_picker( + tr("Font Color"), saved_text_fore_color + ) + config.ui["text_fore_color"] = params.text_fore_color + + with font_cols[1]: + saved_font_size = config.ui.get("font_size", 60) + params.font_size = st.slider(tr("Font Size"), 30, 100, saved_font_size) + config.ui["font_size"] = params.font_size + + stroke_cols = st.columns([0.3, 0.7]) + with stroke_cols[0]: + params.stroke_color = st.color_picker(tr("Stroke Color"), "#000000") + with stroke_cols[1]: + params.stroke_width = st.slider(tr("Stroke Width"), 0.0, 10.0, 1.5) + with st.expander(tr("Click to show API Key management"), expanded=False): + st.subheader(tr("Manage Pexels and Pixabay API Keys")) + + col1, col2 = st.tabs(["Pexels API Keys", "Pixabay API Keys"]) + + with col1: + st.subheader("Pexels API Keys") + if config.app["pexels_api_keys"]: + st.write(tr("Current Keys:")) + for key in config.app["pexels_api_keys"]: + st.code(key) + else: + st.info(tr("No Pexels API Keys currently")) + + new_key = st.text_input(tr("Add Pexels API Key"), key="pexels_new_key") + if st.button(tr("Add Pexels API Key")): + if new_key and new_key not in config.app["pexels_api_keys"]: + config.app["pexels_api_keys"].append(new_key) + config.save_config() + st.success(tr("Pexels API Key added successfully")) + elif new_key in config.app["pexels_api_keys"]: + st.warning(tr("This API Key already exists")) + else: + st.error(tr("Please enter a valid API Key")) + + if config.app["pexels_api_keys"]: + delete_key = st.selectbox( + tr("Select Pexels API Key to delete"), config.app["pexels_api_keys"], key="pexels_delete_key" + ) + if st.button(tr("Delete Selected Pexels API Key")): + config.app["pexels_api_keys"].remove(delete_key) + config.save_config() + st.success(tr("Pexels API Key deleted successfully")) + + with col2: + st.subheader("Pixabay API Keys") + + if config.app["pixabay_api_keys"]: + st.write(tr("Current Keys:")) + for key in config.app["pixabay_api_keys"]: + st.code(key) + else: + st.info(tr("No Pixabay API Keys currently")) + + new_key = st.text_input(tr("Add Pixabay API Key"), key="pixabay_new_key") + if st.button(tr("Add Pixabay API Key")): + if new_key and new_key not in config.app["pixabay_api_keys"]: + config.app["pixabay_api_keys"].append(new_key) + config.save_config() + st.success(tr("Pixabay API Key added successfully")) + elif new_key in config.app["pixabay_api_keys"]: + st.warning(tr("This API Key already exists")) + else: + st.error(tr("Please enter a valid API Key")) + + if config.app["pixabay_api_keys"]: + delete_key = st.selectbox( + tr("Select Pixabay API Key to delete"), config.app["pixabay_api_keys"], key="pixabay_delete_key" + ) + if st.button(tr("Delete Selected Pixabay API Key")): + config.app["pixabay_api_keys"].remove(delete_key) + config.save_config() + st.success(tr("Pixabay API Key deleted successfully")) + +start_button = st.button(tr("Generate Video"), use_container_width=True, type="primary") +if start_button: + config.save_config() + task_id = str(uuid4()) + if not params.video_subject and not params.video_script: + st.error(tr("Video Script and Subject Cannot Both Be Empty")) + scroll_to_bottom() + st.stop() + + if params.video_source not in ["pexels", "pixabay", "local"]: + st.error(tr("Please Select a Valid Video Source")) + scroll_to_bottom() + st.stop() + + if params.video_source == "pexels" and not config.app.get("pexels_api_keys", ""): + st.error(tr("Please Enter the Pexels API Key")) + scroll_to_bottom() + st.stop() + + if params.video_source == "pixabay" and not config.app.get("pixabay_api_keys", ""): + st.error(tr("Please Enter the Pixabay API Key")) + scroll_to_bottom() + st.stop() + + if uploaded_audio_file: + task_dir = utils.task_dir(task_id) + # 上传文件名来自浏览器,不能直接拼到磁盘路径里;这里只保留扩展名, + # 并使用固定文件名保存到当前任务目录,避免路径穿越或特殊字符问题。 + _, audio_ext = os.path.splitext(os.path.basename(uploaded_audio_file.name)) + audio_ext = audio_ext.lower() or ".mp3" + custom_audio_path = os.path.join(task_dir, f"custom-audio{audio_ext}") + with open(custom_audio_path, "wb") as f: + f.write(uploaded_audio_file.getbuffer()) + params.custom_audio_file = custom_audio_path + + if uploaded_files: + local_videos_dir = utils.storage_dir("local_videos", create=True) + # 每次重新上传时都以本次选择的素材为准,避免旧素材不断重复追加。 + params.video_materials = [] + persisted_local_materials = [] + for file in uploaded_files: + file_path = os.path.join(local_videos_dir, f"{file.file_id}_{file.name}") + with open(file_path, "wb") as f: + f.write(file.getbuffer()) + m = MaterialInfo() + m.provider = "local" + m.url = file_path + params.video_materials.append(m) + persisted_local_materials.append( + { + "provider": m.provider, + "url": m.url, + "duration": m.duration, + } + ) + # 将已上传并保存到本地的视频素材写入会话,供后续只改文案时直接复用。 + st.session_state["local_video_materials"] = persisted_local_materials + elif params.video_source == "local" and st.session_state["local_video_materials"]: + # 当用户没有重新上传文件时,复用最近一次已经保存到磁盘的本地素材列表。 + params.video_materials = [] + for material in st.session_state["local_video_materials"]: + m = MaterialInfo() + m.provider = material.get("provider", "local") + m.url = material.get("url", "") + m.duration = material.get("duration", 0) + if m.url: + params.video_materials.append(m) + + log_container = st.empty() + log_records = [] + + def log_received(msg): + if config.ui["hide_log"]: + return + with log_container: + log_records.append(msg) + st.code("\n".join(log_records)) + + logger.add(log_received) + + st.toast(tr("Generating Video")) + logger.info(tr("Start Generating Video")) + logger.info(utils.to_json(params)) + scroll_to_bottom() + + result = tm.start(task_id=task_id, params=params) + if not result or "videos" not in result: + st.error(tr("Video Generation Failed")) + logger.error(tr("Video Generation Failed")) + scroll_to_bottom() + st.stop() + + video_files = result.get("videos", []) + st.success(tr("Video Generation Completed")) + 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: + pass + + open_task_folder(task_id) + logger.info(tr("Video Generation Completed")) + scroll_to_bottom() + +config.save_config() diff --git a/webui/i18n/de.json b/webui/i18n/de.json new file mode 100644 index 0000000..75c5365 --- /dev/null +++ b/webui/i18n/de.json @@ -0,0 +1,122 @@ +{ + "Language": "Deutsch", + "Translation": { + "Login Required": "Anmeldung erforderlich", + "Please login to access settings": "Bitte melden Sie sich an, um auf die Einstellungen zuzugreifen", + "Username": "Benutzername", + "Password": "Passwort", + "Login": "Anmelden", + "Login Error": "Anmeldefehler", + "Incorrect username or password": "Falscher Benutzername oder Passwort", + "Please enter your username and password": "Bitte geben Sie Ihren Benutzernamen und Ihr Passwort ein", + "Video Script Settings": "**Drehbuch / Topic des Videos**", + "Video Subject": "Worum soll es in dem Video gehen? (Geben Sie ein Keyword an, :red[Dank KI wird automatisch ein Drehbuch generieren])", + "Script Language": "Welche Sprache soll zum Generieren von Drehbüchern verwendet werden? :red[KI generiert anhand dieses Begriffs das Drehbuch]", + "Generate Video Script and Keywords": "Klicken Sie hier, um mithilfe von KI ein [Video Drehbuch] und [Video Keywords] basierend auf dem **Keyword** zu generieren.", + "Auto Detect": "Automatisch erkennen", + "Video Script": "Drehbuch (Storybook) (:blue[① Optional, KI generiert ② Die richtige Zeichensetzung hilft bei der Erstellung von Untertiteln])", + "Generate Video Keywords": "Klicken Sie, um KI zum Generieren zu verwenden [Video Keywords] basierend auf dem **Drehbuch**", + "Please Enter the Video Subject": "Bitte geben Sie zuerst das Drehbuch an", + "Generating Video Script and Keywords": "KI generiert ein Drehbuch und Schlüsselwörter...", + "Generating Video Keywords": "KI generiert Video-Schlüsselwörter...", + "Video Keywords": "Video Schlüsselwörter (:blue[① Optional, KI generiert ② Verwende **, (Kommas)** zur Trennung der Wörter, in englischer Sprache])", + "Video Settings": "**Video Einstellungen**", + "Video Concat Mode": "Videoverkettungsmodus", + "Random": "Zufällige Verkettung (empfohlen)", + "Sequential": "Sequentielle Verkettung", + "Video Transition Mode": "Video Übergangsmodus", + "None": "Kein Übergang", + "Shuffle": "Zufällige Übergänge", + "FadeIn": "FadeIn", + "FadeOut": "FadeOut", + "SlideIn": "SlideIn", + "SlideOut": "SlideOut", + "Video Ratio": "Video-Seitenverhältnis", + "Portrait": "Portrait 9:16", + "Landscape": "Landschaft 16:9", + "Clip Duration": "Maximale Dauer einzelner Videoclips in sekunden", + "Number of Videos Generated Simultaneously": "Anzahl der parallel generierten Videos", + "Audio Settings": "**Audio Einstellungen**", + "Speech Synthesis": "Sprachausgabe", + "Speech Region": "Region(:red[Erforderlich,[Region abrufen](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Key": "API-Schlüssel(:red[Erforderlich,[API-Schlüssel abrufen](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Volume": "Lautstärke der Sprachausgabe", + "Speech Rate": "Lesegeschwindigkeit (1,0 bedeutet 1x)", + "Male": "Männlich", + "Female": "Weiblich", + "Background Music": "Hintergrundmusik", + "No Background Music": "Ohne Hintergrundmusik", + "Random Background Music": "Zufällig erzeugte Hintergrundmusik", + "Custom Background Music": "Benutzerdefinierte Hintergrundmusik", + "Custom Background Music File": "Bitte gib den Pfad zur Musikdatei an:", + "Background Music Volume": "Lautstärke: (0.2 entspricht 20%, sollte nicht zu laut sein)", + "Subtitle Settings": "**Untertitel-Einstellungen**", + "Enable Subtitles": "Untertitel aktivieren (Wenn diese Option deaktiviert ist, werden die Einstellungen nicht genutzt)", + "Font": "Schriftart des Untertitels", + "Position": "Ausrichtung des Untertitels", + "Top": "Oben", + "Center": "Mittig", + "Bottom": "Unten (empfohlen)", + "Custom": "Benutzerdefinierte Position (70, was 70% von oben bedeutet)", + "Font Size": "Schriftgröße für Untertitel", + "Font Color": "Schriftfarbe", + "Stroke Color": "Kontur", + "Stroke Width": "Breite der Untertitelkontur", + "Generate Video": "Generiere Videos durch KI", + "Video Script and Subject Cannot Both Be Empty": "Das Video-Thema und Drehbuch dürfen nicht beide leer sein", + "Generating Video": "Video wird erstellt, bitte warten...", + "Start Generating Video": "Beginne mit der Generierung", + "Video Generation Completed": "Video erfolgreich generiert", + "Video Generation Failed": "Video Generierung fehlgeschlagen", + "You can download the generated video from the following links": "Sie können das generierte Video über die folgenden Links herunterladen", + "Basic Settings": "**Grundeinstellungen** (:blue[Klicken zum Erweitern])", + "Language": "Sprache", + "Pexels API Key": "Pexels API-Schlüssel ([API-Schlüssel abrufen](https://www.pexels.com/api/))", + "Pixabay API Key": "Pixabay API-Schlüssel ([API-Schlüssel abrufen](https://pixabay.com/api/docs/#api_search_videos))", + "LLM Provider": "KI-Modellanbieter", + "API Key": "API-Schlüssel (:red[Erforderlich])", + "Base Url": "Basis-URL", + "Account ID": "Konto-ID (Aus dem Cloudflare-Dashboard)", + "Model Name": "Modellname", + "Please Enter the LLM API Key": "Bitte geben Sie den **KI-Modell API-Schlüssel** ein", + "Please Enter the Pexels API Key": "Bitte geben Sie den **Pexels API-Schlüssel** ein", + "Please Enter the Pixabay API Key": "Bitte geben Sie den **Pixabay API-Schlüssel** ein", + "Get Help": "Wenn Sie Hilfe benötigen oder Fragen haben, können Sie dem Discord beitreten: https://harryai.cc", + "Video Source": "Videoquelle", + "TikTok": "TikTok (TikTok-Unterstützung kommt bald)", + "Bilibili": "Bilibili (Bilibili-Unterstützung kommt bald)", + "Xiaohongshu": "Xiaohongshu (Xiaohongshu-Unterstützung kommt bald)", + "Local file": "Lokale Datei", + "Play Voice": "Sprachausgabe abspielen", + "Voice Example": "Dies ist ein Beispieltext zum Testen der Sprachsynthese", + "Synthesizing Voice": "Sprachsynthese läuft, bitte warten...", + "TTS Provider": "Sprachsynthese-Anbieter auswählen", + "TTS Servers": "TTS-Server", + "No voices available for the selected TTS server. Please select another server.": "Keine Stimmen für den ausgewählten TTS-Server verfügbar. Bitte wählen Sie einen anderen Server.", + "SiliconFlow API Key": "SiliconFlow API-Schlüssel", + "SiliconFlow TTS Settings": "SiliconFlow TTS-Einstellungen", + "Speed: Range [0.25, 4.0], default is 1.0": "Geschwindigkeit: Bereich [0.25, 4.0], Standardwert ist 1.0", + "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Lautstärke: Verwendet die Sprachlautstärke-Einstellung, Standardwert 1.0 entspricht Verstärkung 0", + "Hide Log": "Protokoll ausblenden", + "Hide Basic Settings": "Basis-Einstellungen ausblenden\n\nWenn diese Option deaktiviert ist, wird die Basis-Einstellungen-Leiste nicht auf der Seite angezeigt.\n\nWenn Sie sie erneut anzeigen möchten, setzen Sie `hide_config = false` in `config.toml`", + "LLM Settings": "**LLM-Einstellungen**", + "Video Source Settings": "**Videoquellen-Einstellungen**", + "Click to show API Key management": "Klicken Sie hier, um die API-Schlüsselverwaltung anzuzeigen", + "Manage Pexels and Pixabay API Keys": "Pexels- und Pixabay-API-Schlüssel verwalten", + "Current Keys:": "Aktuelle Schlüssel:", + "No Pexels API Keys currently": "Derzeit keine Pexels-API-Schlüssel", + "Add Pexels API Key": "Pexels-API-Schlüssel hinzufügen", + "Pexels API Key added successfully": "Pexels-API-Schlüssel erfolgreich hinzugefügt", + "This API Key already exists": "Dieser API-Schlüssel existiert bereits", + "Please enter a valid API Key": "Bitte geben Sie einen gültigen API-Schlüssel ein", + "Select Pexels API Key to delete": "Wählen Sie den zu löschenden Pexels-API-Schlüssel aus", + "Delete Selected Pexels API Key": "Ausgewählten Pexels-API-Schlüssel löschen", + "Pexels API Key deleted successfully": "Pexels-API-Schlüssel erfolgreich gelöscht", + "No Pixabay API Keys currently": "Derzeit keine Pixabay-API-Schlüssel", + "Add Pixabay API Key": "Pixabay-API-Schlüssel hinzufügen", + "Pixabay API Key added successfully": "Pixabay-API-Schlüssel erfolgreich hinzugefügt", + "Select Pixabay API Key to delete": "Wählen Sie den zu löschenden Pixabay-API-Schlüssel aus", + "Delete Selected Pixabay API Key": "Ausgewählten Pixabay-API-Schlüssel löschen", + "Pixabay API Key deleted successfully": "Pixabay-API-Schlüssel erfolgreich gelöscht" + } +} \ No newline at end of file diff --git a/webui/i18n/en.json b/webui/i18n/en.json new file mode 100644 index 0000000..dda0ca7 --- /dev/null +++ b/webui/i18n/en.json @@ -0,0 +1,135 @@ +{ + "Language": "English", + "Translation": { + "Login Required": "Login Required", + "Please login to access settings": "Please login to access settings", + "Username": "Username", + "Password": "Password", + "Login": "Login", + "Login Error": "Login Error", + "Incorrect username or password": "Incorrect username or password", + "Please enter your username and password": "Please enter your username and password", + "Video Script Settings": "**Video Script Settings**", + "Video Subject": "Video Subject (Provide a keyword, :red[AI will automatically generate] video script)", + "Script Language": "Language for Generating Video Script (AI will automatically output based on the language of your subject)", + "Advanced Script Settings": "Advanced Script Settings", + "Script Paragraph Number": "Script Paragraph Count", + "Custom Script Requirements": "Custom Script Requirements", + "Custom Script Requirements Placeholder": "Example: use a lighter tone, fit TikTok style, target young viewers, make the opening more suspenseful", + "Use Custom System Prompt": "Use Custom System Prompt", + "Use Custom System Prompt Help": "When enabled, this replaces the default script generation rules. Intended for advanced prompt users.", + "Custom System Prompt": "Custom System Prompt", + "Generate Video Script and Keywords": "Click to use AI to generate [Video Script] and [Video Keywords] based on **subject**", + "Auto Detect": "Auto Detect", + "Video Script": "Video Script (:blue[① Optional, AI generated ② Proper punctuation helps with subtitle generation])", + "Generate Video Keywords": "Click to use AI to generate [Video Keywords] based on **script**", + "Please Enter the Video Subject": "Please Enter the Video Script First", + "Generating Video Script and Keywords": "AI is generating video script and keywords...", + "Generating Video Keywords": "AI is generating video keywords...", + "Video Keywords": "Video Keywords (:blue[① Optional, AI generated ② Use **English commas** for separation, English only])", + "Video Settings": "**Video Settings**", + "Video Concat Mode": "Video Concatenation Mode", + "Random": "Random Concatenation (Recommended)", + "Sequential": "Sequential Concatenation", + "Video Transition Mode": "Video Transition Mode", + "None": "None", + "Shuffle": "Shuffle", + "FadeIn": "FadeIn", + "FadeOut": "FadeOut", + "SlideIn": "SlideIn", + "SlideOut": "SlideOut", + "Video Ratio": "Video Aspect Ratio", + "Portrait": "Portrait 9:16", + "Landscape": "Landscape 16:9", + "Clip Duration": "Maximum Duration of Video Clips (seconds)", + "Number of Videos Generated Simultaneously": "Number of Videos Generated Simultaneously", + "Audio Settings": "**Audio Settings**", + "Speech Synthesis": "Speech Synthesis Voice", + "Speech Region": "Region(:red[Required,[Get Region](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Key": "API Key(:red[Required,[Get API Key](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Volume": "Speech Volume (1.0 represents 100%)", + "Speech Rate": "Speech Rate (1.0 means 1x speed)", + "Custom Audio File": "Custom Audio File", + "Custom audio will be used directly. TTS synthesis will be skipped for this task.": "Custom audio will be used directly. TTS synthesis will be skipped for this task.", + "Male": "Male", + "Female": "Female", + "Background Music": "Background Music", + "No Background Music": "No Background Music", + "Random Background Music": "Random Background Music", + "Custom Background Music": "Custom Background Music", + "Custom Background Music File": "Please enter the file path for custom background music:", + "Background Music Volume": "Background Music Volume (0.2 represents 20%, background music should not be too loud)", + "Subtitle Settings": "**Subtitle Settings**", + "Enable Subtitles": "Enable Subtitles (If unchecked, the settings below will not take effect)", + "Font": "Subtitle Font", + "Position": "Subtitle Position", + "Top": "Top", + "Center": "Center", + "Bottom": "Bottom (Recommended)", + "Custom": "Custom position (70, indicating 70% down from the top)", + "Font Size": "Subtitle Font Size", + "Font Color": "Subtitle Font Color", + "Stroke Color": "Subtitle Outline Color", + "Stroke Width": "Subtitle Outline Width", + "Generate Video": "Generate Video", + "Video Script and Subject Cannot Both Be Empty": "Video Subject and Video Script cannot both be empty", + "Generating Video": "Generating video, please wait...", + "Start Generating Video": "Start Generating Video", + "Video Generation Completed": "Video Generation Completed", + "Video Generation Failed": "Video Generation Failed", + "You can download the generated video from the following links": "You can download the generated video from the following links", + "Pexels API Key": "Pexels API Key ([Get API Key](https://www.pexels.com/api/))", + "Pixabay API Key": "Pixabay API Key ([Get API Key](https://pixabay.com/api/docs/#api_search_videos))", + "Basic Settings": "**Basic Settings** (:blue[Click to expand])", + "Language": "Language", + "LLM Provider": "LLM Provider", + "API Key": "API Key (:red[Required])", + "Base Url": "Base Url", + "Account ID": "Account ID (Get from Cloudflare dashboard)", + "Model Name": "Model Name", + "Please Enter the LLM API Key": "Please Enter the **LLM API Key**", + "Please Enter the Pexels API Key": "Please Enter the **Pexels API Key**", + "Please Enter the Pixabay API Key": "Please Enter the **Pixabay API Key**", + "Get Help": "If you need help, or have any questions, you can join discord for help: https://harryai.cc", + "Video Source": "Video Source", + "TikTok": "TikTok (TikTok support is coming soon)", + "Bilibili": "Bilibili (Bilibili support is coming soon)", + "Xiaohongshu": "Xiaohongshu (Xiaohongshu support is coming soon)", + "Local file": "Local file", + "Play Voice": "Play Voice", + "Voice Example": "This is an example text for testing speech synthesis", + "Synthesizing Voice": "Synthesizing voice, please wait...", + "TTS Provider": "Select the voice synthesis provider", + "TTS Servers": "TTS Servers", + "No voices available for the selected TTS server. Please select another server.": "No voices available for the selected TTS server. Please select another server.", + "SiliconFlow API Key": "SiliconFlow API Key [Click to get](https://cloud.siliconflow.cn/account/ak)", + "SiliconFlow TTS Settings": "SiliconFlow TTS Settings", + "Speed: Range [0.25, 4.0], default is 1.0": "Speed: Range [0.25, 4.0], default is 1.0", + "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0", + "MiMo API Key": "Xiaomi MiMo API Key [Click to get](https://platform.xiaomimimo.com/docs/zh-CN/quick-start/first-api-call)", + "MiMo TTS Settings": "Xiaomi MiMo TTS Settings", + "Uses Xiaomi MiMo V2.5 TTS preset voices": "Uses Xiaomi MiMo V2.5 TTS preset voices", + "Speed and volume are currently handled by the provider defaults": "Speed and volume are currently handled by the provider defaults", + "Hide Log": "Hide Log", + "Hide Basic Settings": "Hide Basic Settings\n\nHidden, the basic settings panel will not be displayed on the page.\n\nIf you need to display it again, please set `hide_config = false` in `config.toml`", + "LLM Settings": "**LLM Settings**", + "Video Source Settings": "**Video Source Settings**", + "Click to show API Key management": "Click to show API Key management", + "Manage Pexels and Pixabay API Keys": "Manage Pexels and Pixabay API Keys", + "Current Keys:": "Current Keys:", + "No Pexels API Keys currently": "No Pexels API Keys currently", + "Add Pexels API Key": "Add Pexels API Key", + "Pexels API Key added successfully": "Pexels API Key added successfully", + "This API Key already exists": "This API Key already exists", + "Please enter a valid API Key": "Please enter a valid API Key", + "Select Pexels API Key to delete": "Select Pexels API Key to delete", + "Delete Selected Pexels API Key": "Delete Selected Pexels API Key", + "Pexels API Key deleted successfully": "Pexels API Key deleted successfully", + "No Pixabay API Keys currently": "No Pixabay API Keys currently", + "Add Pixabay API Key": "Add Pixabay API Key", + "Pixabay API Key added successfully": "Pixabay API Key added successfully", + "Select Pixabay API Key to delete": "Select Pixabay API Key to delete", + "Delete Selected Pixabay API Key": "Delete Selected Pixabay API Key", + "Pixabay API Key deleted successfully": "Pixabay API Key deleted successfully" + } +} diff --git a/webui/i18n/pt.json b/webui/i18n/pt.json new file mode 100644 index 0000000..90c47c6 --- /dev/null +++ b/webui/i18n/pt.json @@ -0,0 +1,122 @@ +{ + "Language": "Português Brasileiro", + "Translation": { + "Login Required": "Login Necessário", + "Please login to access settings": "Por favor, faça login para acessar as configurações", + "Username": "Nome de usuário", + "Password": "Senha", + "Login": "Entrar", + "Login Error": "Erro de Login", + "Incorrect username or password": "Nome de usuário ou senha incorretos", + "Please enter your username and password": "Por favor, digite seu nome de usuário e senha", + "Video Script Settings": "**Configurações do Roteiro do Vídeo**", + "Video Subject": "Tema do Vídeo (Forneça uma palavra-chave, :red[a IA irá gerar automaticamente] o roteiro do vídeo)", + "Script Language": "Idioma para Gerar o Roteiro do Vídeo (a IA irá gerar automaticamente com base no idioma do seu tema)", + "Generate Video Script and Keywords": "Clique para usar a IA para gerar o [Roteiro do Vídeo] e as [Palavras-chave do Vídeo] com base no **tema**", + "Auto Detect": "Detectar Automaticamente", + "Video Script": "Roteiro do Vídeo (:blue[① Opcional, gerado pela IA ② Pontuação adequada ajuda na geração de legendas])", + "Generate Video Keywords": "Clique para usar a IA para gerar [Palavras-chave do Vídeo] com base no **roteiro**", + "Please Enter the Video Subject": "Por favor, insira o Roteiro do Vídeo primeiro", + "Generating Video Script and Keywords": "A IA está gerando o roteiro do vídeo e as palavras-chave...", + "Generating Video Keywords": "A IA está gerando as palavras-chave do vídeo...", + "Video Keywords": "Palavras-chave do Vídeo (:blue[① Opcional, gerado pela IA ② Use **vírgulas em inglês** para separar, somente em inglês])", + "Video Settings": "**Configurações do Vídeo**", + "Video Concat Mode": "Modo de Concatenação de Vídeo", + "Random": "Concatenação Aleatória (Recomendado)", + "Sequential": "Concatenação Sequencial", + "Video Transition Mode": "Modo de Transição de Vídeo", + "None": "Nenhuma Transição", + "Shuffle": "Transição Aleatória", + "FadeIn": "FadeIn", + "FadeOut": "FadeOut", + "SlideIn": "SlideIn", + "SlideOut": "SlideOut", + "Video Ratio": "Proporção do Vídeo", + "Portrait": "Retrato 9:16", + "Landscape": "Paisagem 16:9", + "Clip Duration": "Duração Máxima dos Clipes de Vídeo (segundos)", + "Number of Videos Generated Simultaneously": "Número de Vídeos Gerados Simultaneamente", + "Audio Settings": "**Configurações de Áudio**", + "Speech Synthesis": "Voz de Síntese de Fala", + "Speech Region": "Região(:red[Obrigatório,[Obter Região](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Key": "Chave da API(:red[Obrigatório,[Obter Chave da API](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Volume": "Volume da Fala (1.0 representa 100%)", + "Speech Rate": "Velocidade da Fala (1.0 significa velocidade 1x)", + "Male": "Masculino", + "Female": "Feminino", + "Background Music": "Música de Fundo", + "No Background Music": "Sem Música de Fundo", + "Random Background Music": "Música de Fundo Aleatória", + "Custom Background Music": "Música de Fundo Personalizada", + "Custom Background Music File": "Por favor, insira o caminho do arquivo para a música de fundo personalizada:", + "Background Music Volume": "Volume da Música de Fundo (0.2 representa 20%, a música de fundo não deve ser muito alta)", + "Subtitle Settings": "**Configurações de Legendas**", + "Enable Subtitles": "Ativar Legendas (Se desmarcado, as configurações abaixo não terão efeito)", + "Font": "Fonte da Legenda", + "Position": "Posição da Legenda", + "Top": "Superior", + "Center": "Centralizar", + "Bottom": "Inferior (Recomendado)", + "Custom": "Posição personalizada (70, indicando 70% abaixo do topo)", + "Font Size": "Tamanho da Fonte da Legenda", + "Font Color": "Cor da Fonte da Legenda", + "Stroke Color": "Cor do Contorno da Legenda", + "Stroke Width": "Largura do Contorno da Legenda", + "Generate Video": "Gerar Vídeo", + "Video Script and Subject Cannot Both Be Empty": "O Tema do Vídeo e o Roteiro do Vídeo não podem estar ambos vazios", + "Generating Video": "Gerando vídeo, por favor aguarde...", + "Start Generating Video": "Começar a Gerar Vídeo", + "Video Generation Completed": "Geração do Vídeo Concluída", + "Video Generation Failed": "Falha na Geração do Vídeo", + "You can download the generated video from the following links": "Você pode baixar o vídeo gerado a partir dos seguintes links", + "Basic Settings": "**Configurações Básicas** (:blue[Clique para expandir])", + "Language": "Idioma", + "Pexels API Key": "Chave da API do Pexels ([Obter Chave da API](https://www.pexels.com/api/))", + "Pixabay API Key": "Chave da API do Pixabay ([Obter Chave da API](https://pixabay.com/api/docs/#api_search_videos))", + "LLM Provider": "Provedor LLM", + "API Key": "Chave da API (:red[Obrigatório])", + "Base Url": "URL Base", + "Account ID": "ID da Conta (Obter no painel do Cloudflare)", + "Model Name": "Nome do Modelo", + "Please Enter the LLM API Key": "Por favor, insira a **Chave da API LLM**", + "Please Enter the Pexels API Key": "Por favor, insira a **Chave da API do Pexels**", + "Please Enter the Pixabay API Key": "Por favor, insira a **Chave da API do Pixabay**", + "Get Help": "Se precisar de ajuda ou tiver alguma dúvida, você pode entrar no discord para obter ajuda: https://harryai.cc", + "Video Source": "Fonte do Vídeo", + "TikTok": "TikTok (Suporte para TikTok em breve)", + "Bilibili": "Bilibili (Suporte para Bilibili em breve)", + "Xiaohongshu": "Xiaohongshu (Suporte para Xiaohongshu em breve)", + "Local file": "Arquivo local", + "Play Voice": "Reproduzir Voz", + "Voice Example": "Este é um exemplo de texto para testar a síntese de fala", + "Synthesizing Voice": "Sintetizando voz, por favor aguarde...", + "TTS Provider": "Selecione o provedor de síntese de voz", + "TTS Servers": "Servidores TTS", + "No voices available for the selected TTS server. Please select another server.": "Não há vozes disponíveis para o servidor TTS selecionado. Por favor, selecione outro servidor.", + "SiliconFlow API Key": "Chave API do SiliconFlow", + "SiliconFlow TTS Settings": "Configurações do SiliconFlow TTS", + "Speed: Range [0.25, 4.0], default is 1.0": "Velocidade: Intervalo [0.25, 4.0], o padrão é 1.0", + "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Volume: Usa a configuração de Volume de Fala, o padrão 1.0 corresponde ao ganho 0", + "Hide Log": "Ocultar Log", + "Hide Basic Settings": "Ocultar Configurações Básicas\n\nOculto, o painel de configurações básicas não será exibido na página.\n\nSe precisar exibi-lo novamente, defina `hide_config = false` em `config.toml`", + "LLM Settings": "**Configurações do LLM**", + "Video Source Settings": "**Configurações da Fonte do Vídeo**", + "Click to show API Key management": "Clique para mostrar o gerenciamento de chaves de API", + "Manage Pexels and Pixabay API Keys": "Gerenciar chaves de API do Pexels e Pixabay", + "Current Keys:": "Chaves atuais:", + "No Pexels API Keys currently": "Nenhuma chave de API do Pexels atualmente", + "Add Pexels API Key": "Adicionar chave de API do Pexels", + "Pexels API Key added successfully": "Chave de API do Pexels adicionada com sucesso", + "This API Key already exists": "Esta chave de API já existe", + "Please enter a valid API Key": "Por favor, insira uma chave de API válida", + "Select Pexels API Key to delete": "Selecione a chave de API do Pexels para excluir", + "Delete Selected Pexels API Key": "Excluir chave de API do Pexels selecionada", + "Pexels API Key deleted successfully": "Chave de API do Pexels excluída com sucesso", + "No Pixabay API Keys currently": "Nenhuma chave de API do Pixabay atualmente", + "Add Pixabay API Key": "Adicionar chave de API do Pixabay", + "Pixabay API Key added successfully": "Chave de API do Pixabay adicionada com sucesso", + "Select Pixabay API Key to delete": "Selecione a chave de API do Pixabay para excluir", + "Delete Selected Pixabay API Key": "Excluir chave de API do Pixabay selecionada", + "Pixabay API Key deleted successfully": "Chave de API do Pixabay excluída com sucesso" + } +} \ No newline at end of file diff --git a/webui/i18n/ru.json b/webui/i18n/ru.json new file mode 100644 index 0000000..77b34ae --- /dev/null +++ b/webui/i18n/ru.json @@ -0,0 +1,105 @@ +{ + "Language": "Русский", + "Translation": { + "Login Required": "Требуется авторизация", + "Please login to access settings": "Войдите для доступа к настройкам", + "Username": "Имя пользователя", + "Password": "Пароль", + "Login": "Войти", + "Login Error": "Ошибка входа", + "Incorrect username or password": "Неверное имя пользователя или пароль", + "Please enter your username and password": "Введите имя пользователя и пароль", + "Video Script Settings": "**Настройки сценария видео**", + "Video Subject": "Тема видео (Укажите ключевое слово, :red[ИИ автоматически создаст] сценарий видео)", + "Script Language": "Язык сценария видео (ИИ автоматически определит язык по теме)", + "Generate Video Script and Keywords": "Нажмите для создания [Сценария видео] и [Ключевых слов] на основе **темы** с помощью ИИ", + "Auto Detect": "Автоопределение", + "Video Script": "Сценарий видео (:blue[① Необязательно, создается ИИ ② Правильная пунктуация помогает генерации субтитров])", + "Generate Video Keywords": "Нажмите для создания [Ключевых слов] на основе **сценария** с помощью ИИ", + "Please Enter the Video Subject": "Сначала введите сценарий видео", + "Generating Video Script and Keywords": "ИИ создает сценарий и ключевые слова...", + "Generating Video Keywords": "ИИ создает ключевые слова...", + "Video Keywords": "Ключевые слова видео (:blue[① Необязательно, создается ИИ ② Используйте **запятые** для разделения, только на английском])", + "Video Settings": "**Настройки видео**", + "Video Concat Mode": "Режим объединения видео", + "Random": "Случайное объединение (Рекомендуется)", + "Sequential": "Последовательное объединение", + "Video Transition Mode": "Режим переходов видео", + "None": "Без переходов", + "Shuffle": "Перемешивание", + "FadeIn": "Появление", + "FadeOut": "Затухание", + "SlideIn": "Вход слайдом", + "SlideOut": "Выход слайдом", + "Video Ratio": "Соотношение сторон видео", + "Portrait": "Вертикальное 9:16", + "Landscape": "Горизонтальное 16:9", + "Clip Duration": "Максимальная длительность видеофрагмента (секунды)", + "Number of Videos Generated Simultaneously": "Количество одновременно создаваемых видео", + "Audio Settings": "**Настройки аудио**", + "Speech Synthesis": "Голос синтеза речи", + "Speech Region": "Регион (:red[Обязательно,[Получить регион](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Key": "API-ключ (:red[Обязательно,[Получить API-ключ](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Volume": "Громкость речи (1.0 соответствует 100%)", + "Speech Rate": "Скорость речи (1.0 означает обычную скорость)", + "Male": "Мужской", + "Female": "Женский", + "Background Music": "Фоновая музыка", + "No Background Music": "Без фоновой музыки", + "Random Background Music": "Случайная фоновая музыка", + "Custom Background Music": "Пользовательская фоновая музыка", + "Custom Background Music File": "Введите путь к файлу пользовательской фоновой музыки:", + "Background Music Volume": "Громкость фоновой музыки (0.2 соответствует 20%, фоновая музыка не должна быть слишком громкой)", + "Subtitle Settings": "**Настройки субтитров**", + "Enable Subtitles": "Включить субтитры (Если не отмечено, настройки ниже не будут применены)", + "Font": "Шрифт субтитров", + "Position": "Расположение субтитров", + "Top": "Сверху", + "Center": "По центру", + "Bottom": "Снизу (Рекомендуется)", + "Custom": "Пользовательское расположение (70, означает 70% от верха)", + "Font Size": "Размер шрифта субтитров", + "Font Color": "Цвет шрифта субтитров", + "Stroke Color": "Цвет обводки субтитров", + "Stroke Width": "Ширина обводки субтитров", + "Generate Video": "Создать видео", + "Video Script and Subject Cannot Both Be Empty": "Тема и сценарий видео не могут быть пустыми одновременно", + "Generating Video": "Создание видео, пожалуйста подождите...", + "Start Generating Video": "Начать создание видео", + "Video Generation Completed": "Создание видео завершено", + "Video Generation Failed": "Ошибка создания видео", + "You can download the generated video from the following links": "Вы можете скачать созданное видео по следующим ссылкам", + "Pexels API Key": "Pexels API-ключ ([Получить API-ключ](https://www.pexels.com/api/))", + "Pixabay API Key": "Pixabay API-ключ ([Получить API-ключ](https://pixabay.com/api/docs/#api_search_videos))", + "Basic Settings": "**Основные настройки** (:blue[Нажмите для раскрытия])", + "Language": "Язык", + "LLM Provider": "Провайдер LLM", + "API Key": "API-ключ (:red[Обязательно])", + "Base Url": "Базовый URL", + "Account ID": "ID аккаунта (Получите в панели управления Cloudflare)", + "Model Name": "Название модели", + "Please Enter the LLM API Key": "Введите **LLM API-ключ**", + "Please Enter the Pexels API Key": "Введите **Pexels API-ключ**", + "Please Enter the Pixabay API Key": "Введите **Pixabay API-ключ**", + "Get Help": "Если нужна помощь или есть вопросы, присоединяйтесь к Discord: https://harryai.cc", + "Video Source": "Источник видео", + "TikTok": "TikTok (Поддержка TikTok скоро появится)", + "Bilibili": "Bilibili (Поддержка Bilibili скоро появится)", + "Xiaohongshu": "Xiaohongshu (Поддержка Xiaohongshu скоро появится)", + "Local file": "Локальный файл", + "Play Voice": "Воспроизвести голос", + "Voice Example": "Это пример текста для тестирования синтеза речи", + "Synthesizing Voice": "Синтезирование голоса, пожалуйста подождите...", + "TTS Provider": "Выберите провайдера синтеза речи", + "TTS Servers": "TTS серверы", + "No voices available for the selected TTS server. Please select another server.": "Для выбранного TTS сервера нет доступных голосов. Выберите другой сервер.", + "SiliconFlow API Key": "SiliconFlow API-ключ [Нажмите для получения](https://cloud.siliconflow.cn/account/ak)", + "SiliconFlow TTS Settings": "Настройки SiliconFlow TTS", + "Speed: Range [0.25, 4.0], default is 1.0": "Скорость: Диапазон [0.25, 4.0], по умолчанию 1.0", + "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Громкость: Использует настройку громкости речи, по умолчанию 1.0 соответствует усилению 0", + "Hide Log": "Скрыть журнал", + "Hide Basic Settings": "Скрыть основные настройки\n\nПри скрытии панель основных настроек не будет отображаться на странице.\n\nДля повторного отображения установите `hide_config = false` в `config.toml`", + "LLM Settings": "**Настройки LLM**", + "Video Source Settings": "**Настройки источника видео**" + } +} diff --git a/webui/i18n/tr.json b/webui/i18n/tr.json new file mode 100644 index 0000000..1de41d4 --- /dev/null +++ b/webui/i18n/tr.json @@ -0,0 +1,124 @@ +{ + "Language": "Türkçe", + "Translation": { + "Login Required": "Giriş Gerekli", + "Please login to access settings": "Ayarlara erişmek için lütfen giriş yapın", + "Username": "Kullanıcı Adı", + "Password": "Şifre", + "Login": "Giriş Yap", + "Login Error": "Giriş Hatası", + "Incorrect username or password": "Hatalı kullanıcı adı veya şifre", + "Please enter your username and password": "Lütfen kullanıcı adınızı ve şifrenizi girin", + "Video Script Settings": "**Video Senaryo Ayarları**", + "Video Subject": "Video Konusu (:red[Yapay zeka otomatik olarak] video senaryosu oluşturacak bir anahtar kelime girin)", + "Script Language": "Video Senaryosu Dili (Yapay zeka, konunuzun diline göre otomatik çıktı verecektir)", + "Generate Video Script and Keywords": "**Konuya** dayalı olarak yapay zeka ile [Video Senaryosu] ve [Video Anahtar Kelimeleri] oluşturmak için tıklayın", + "Auto Detect": "Otomatik Algılama", + "Video Script": "Video Senaryosu (:blue[① İsteğe bağlı, yapay zeka tarafından oluşturulur ② Doğru noktalama altyazı oluşturmaya yardımcı olur])", + "Generate Video Keywords": "**Senaryoya** dayalı olarak yapay zeka ile [Video Anahtar Kelimeleri] oluşturmak için tıklayın", + "Please Enter the Video Subject": "Lütfen Önce Video Senaryosunu Girin", + "Generating Video Script and Keywords": "Yapay zeka video senaryosu ve anahtar kelimeleri oluşturuyor...", + "Generating Video Keywords": "Yapay zeka video anahtar kelimelerini oluşturuyor...", + "Video Keywords": "Video Anahtar Kelimeleri (:blue[① İsteğe bağlı, yapay zeka tarafından oluşturulur ② Ayırmak için **İngilizce virgül** kullanın, yalnızca İngilizce])", + "Video Settings": "**Video Ayarları**", + "Video Concat Mode": "Video Birleştirme Modu", + "Random": "Rastgele Birleştirme (Önerilen)", + "Sequential": "Sıralı Birleştirme", + "Video Transition Mode": "Video Geçiş Modu", + "None": "Yok", + "Shuffle": "Karıştır", + "FadeIn": "Yavaşça Belirme", + "FadeOut": "Yavaşça Kaybolma", + "SlideIn": "Kayarak Giriş", + "SlideOut": "Kayarak Çıkış", + "Video Ratio": "Video En-Boy Oranı", + "Portrait": "Dikey 9:16", + "Landscape": "Yatay 16:9", + "Clip Duration": "Video Kliplerinin Maksimum Süresi (saniye)", + "Number of Videos Generated Simultaneously": "Aynı Anda Oluşturulan Video Sayısı", + "Audio Settings": "**Ses Ayarları**", + "Speech Synthesis": "Konuşma Sentezi Sesi", + "Speech Region": "Bölge (:red[Gerekli, [Bölge Al](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Key": "API Anahtarı (:red[Gerekli, [API Anahtarı Al](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Volume": "Konuşma Ses Seviyesi (1.0, %100 anlamına gelir)", + "Speech Rate": "Konuşma Hızı (1.0, 1x hız anlamına gelir)", + "Male": "Erkek", + "Female": "Kadın", + "Background Music": "Arka Plan Müziği", + "No Background Music": "Arka Plan Müziği Yok", + "Random Background Music": "Rastgele Arka Plan Müziği", + "Custom Background Music": "Özel Arka Plan Müziği", + "Custom Background Music File": "Lütfen özel arka plan müziği dosya yolunu girin:", + "Background Music Volume": "Arka Plan Müziği Ses Seviyesi (0.2, %20 anlamına gelir, arka plan müziği çok yüksek olmamalı)", + "Subtitle Settings": "**Altyazı Ayarları**", + "Enable Subtitles": "Altyazıları Etkinleştir (İşaretlenmezse aşağıdaki ayarlar geçerli olmaz)", + "Font": "Altyazı Fontu", + "Position": "Altyazı Konumu", + "Top": "Üst", + "Center": "Orta", + "Bottom": "Alt (Önerilen)", + "Custom": "Özel konum (70, üstten %70 aşağı anlamına gelir)", + "Font Size": "Altyazı Font Boyutu", + "Font Color": "Altyazı Font Rengi", + "Stroke Color": "Altyazı Çerçeve Rengi", + "Stroke Width": "Altyazı Çerçeve Kalınlığı", + "Generate Video": "Video Oluştur", + "Video Script and Subject Cannot Both Be Empty": "Video Konusu ve Video Senaryosu aynı anda boş olamaz", + "Generating Video": "Video oluşturuluyor, lütfen bekleyin...", + "Start Generating Video": "Video Oluşturmaya Başla", + "Video Generation Completed": "Video Oluşturma Tamamlandı", + "Video Generation Failed": "Video Oluşturma Başarısız", + "You can download the generated video from the following links": "Oluşturulan videoyu aşağıdaki bağlantılardan indirebilirsiniz", + "Pexels API Key": "Pexels API Anahtarı ([API Anahtarı Al](https://www.pexels.com/api/))", + "Pixabay API Key": "Pixabay API Anahtarı ([API Anahtarı Al](https://pixabay.com/api/docs/#api_search_videos))", + "Basic Settings": "**Temel Ayarlar** (:blue[Genişletmek için tıklayın])", + "Language": "Dil", + "LLM Provider": "LLM Sağlayıcı", + "API Key": "API Anahtarı (:red[Gerekli])", + "Base Url": "Temel URL", + "Account ID": "Hesap Kimlik Numarası (Cloudflare panodan alın)", + "Model Name": "Model Adı", + "Please Enter the LLM API Key": "Lütfen **LLM API Anahtarını** Girin", + "Please Enter the Pexels API Key": "Lütfen **Pexels API Anahtarını** Girin", + "Please Enter the Pixabay API Key": "Lütfen **Pixabay API Anahtarını** Girin", + "Get Help": "Yardıma ihtiyacınız varsa veya sorularınız varsa discord'a katılabilirsiniz: https://harryai.cc", + "Video Source": "Video Kaynağı", + "TikTok": "TikTok (TikTok desteği yakında geliyor)", + "Bilibili": "Bilibili (Bilibili desteği yakında geliyor)", + "Xiaohongshu": "Xiaohongshu (Xiaohongshu desteği yakında geliyor)", + "Local file": "Yerel dosya", + "Play Voice": "Sesi Oynat", + "Voice Example": "Bu, konuşma sentezini test etmek için örnek bir metindir", + "Synthesizing Voice": "Ses sentezleniyor, lütfen bekleyin...", + "TTS Provider": "Ses sentezi sağlayıcısını seçin", + "TTS Servers": "TTS Sunucuları", + "No voices available for the selected TTS server. Please select another server.": "Seçilen TTS sunucusu için uygun ses bulunamadı. Lütfen başka bir sunucu seçin.", + "SiliconFlow API Key": "SiliconFlow API Anahtarı [Almak için tıklayın](https://cloud.siliconflow.cn/account/ak)", + "SiliconFlow TTS Settings": "SiliconFlow TTS Ayarları", + "Speed: Range [0.25, 4.0], default is 1.0": "Hız: Aralık [0.25, 4.0], varsayılan 1.0", + "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Ses Seviyesi: Konuşma Ses Seviyesi ayarını kullanır, varsayılan 1.0 gain 0'a eşittir", + "Hide Log": "Günlüğü Gizle", + "Hide Basic Settings": "Temel Ayarları Gizle\n\nGizlendiği takdirde, temel ayarlar paneli sayfada görüntülenmeyecektir.\n\nTekrar görüntülemek için `config.toml` dosyasında `hide_config = false` olarak ayarlayın", + "LLM Settings": "**LLM Ayarları**", + "Video Source Settings": "**Video Kaynağı Ayarları**", + "Custom Audio File": "Özel Ses Dosyası", + "Custom audio will be used directly. TTS synthesis will be skipped for this task.": "Özel ses doğrudan kullanılacak. Bu görev için TTS sentezi atlanacak.", + "Click to show API Key management": "API Anahtarı yönetimini göstermek için tıklayın", + "Manage Pexels and Pixabay API Keys": "Pexels ve Pixabay API Anahtarlarını Yönet", + "Current Keys:": "Mevcut Anahtarlar:", + "No Pexels API Keys currently": "Şu anda Pexels API Anahtarı yok", + "Add Pexels API Key": "Pexels API Anahtarı Ekle", + "Pexels API Key added successfully": "Pexels API Anahtarı başarıyla eklendi", + "This API Key already exists": "Bu API Anahtarı zaten mevcut", + "Please enter a valid API Key": "Lütfen geçerli bir API Anahtarı girin", + "Select Pexels API Key to delete": "Silinecek Pexels API Anahtarını seçin", + "Delete Selected Pexels API Key": "Seçili Pexels API Anahtarını Sil", + "Pexels API Key deleted successfully": "Pexels API Anahtarı başarıyla silindi", + "No Pixabay API Keys currently": "Şu anda Pixabay API Anahtarı yok", + "Add Pixabay API Key": "Pixabay API Anahtarı Ekle", + "Pixabay API Key added successfully": "Pixabay API Anahtarı başarıyla eklendi", + "Select Pixabay API Key to delete": "Silinecek Pixabay API Anahtarını seçin", + "Delete Selected Pixabay API Key": "Seçili Pixabay API Anahtarını Sil", + "Pixabay API Key deleted successfully": "Pixabay API Anahtarı başarıyla silindi" + } +} diff --git a/webui/i18n/vi.json b/webui/i18n/vi.json new file mode 100644 index 0000000..2ecbaa8 --- /dev/null +++ b/webui/i18n/vi.json @@ -0,0 +1,122 @@ +{ + "Language": "Tiếng Việt", + "Translation": { + "Login Required": "Yêu cầu đăng nhập", + "Please login to access settings": "Vui lòng đăng nhập để truy cập cài đặt", + "Username": "Tên đăng nhập", + "Password": "Mật khẩu", + "Login": "Đăng nhập", + "Login Error": "Lỗi đăng nhập", + "Incorrect username or password": "Tên đăng nhập hoặc mật khẩu không chính xác", + "Please enter your username and password": "Vui lòng nhập tên đăng nhập và mật khẩu của bạn", + "Video Script Settings": "**Cài Đặt Kịch Bản Video**", + "Video Subject": "Chủ Đề Video (Cung cấp một từ khóa, :red[AI sẽ tự động tạo ra] kịch bản video)", + "Script Language": "Ngôn Ngữ cho Việc Tạo Kịch Bản Video (AI sẽ tự động xuất ra dựa trên ngôn ngữ của chủ đề của bạn)", + "Generate Video Script and Keywords": "Nhấn để sử dụng AI để tạo [Kịch Bản Video] và [Từ Khóa Video] dựa trên **chủ đề**", + "Auto Detect": "Tự Động Phát Hiện", + "Video Script": "Kịch Bản Video (:blue[① Tùy chọn, AI tạo ra ② Dấu câu chính xác giúp việc tạo phụ đề)", + "Generate Video Keywords": "Nhấn để sử dụng AI để tạo [Từ Khóa Video] dựa trên **kịch bản**", + "Please Enter the Video Subject": "Vui lòng Nhập Kịch Bản Video Trước", + "Generating Video Script and Keywords": "AI đang tạo kịch bản video và từ khóa...", + "Generating Video Keywords": "AI đang tạo từ khóa video...", + "Video Keywords": "Từ Khóa Video (:blue[① Tùy chọn, AI tạo ra ② Sử dụng dấu phẩy **Tiếng Anh** để phân tách, chỉ sử dụng Tiếng Anh])", + "Video Settings": "**Cài Đặt Video**", + "Video Concat Mode": "Chế Độ Nối Video", + "Random": "Nối Ngẫu Nhiên (Được Khuyến Nghị)", + "Sequential": "Nối Theo Thứ Tự", + "Video Transition Mode": "Chế Độ Chuyển Đổi Video", + "None": "Không Có Chuyển Đổi", + "Shuffle": "Chuyển Đổi Ngẫu Nhiên", + "FadeIn": "FadeIn", + "FadeOut": "FadeOut", + "SlideIn": "SlideIn", + "SlideOut": "SlideOut", + "Video Ratio": "Tỷ Lệ Khung Hình Video", + "Portrait": "Dọc 9:16", + "Landscape": "Ngang 16:9", + "Clip Duration": "Thời Lượng Tối Đa Của Đoạn Video (giây)", + "Number of Videos Generated Simultaneously": "Số Video Được Tạo Ra Đồng Thời", + "Audio Settings": "**Cài Đặt Âm Thanh**", + "Speech Synthesis": "Giọng Đọc Văn Bản", + "Speech Region": "Vùng(:red[Bắt Buộc,[Lấy Vùng](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Key": "Khóa API(:red[Bắt Buộc,[Lấy Khóa API](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Volume": "Âm Lượng Giọng Đọc (1.0 đại diện cho 100%)", + "Speech Rate": "Tốc độ đọc (1.0 biểu thị tốc độ gốc)", + "Male": "Nam", + "Female": "Nữ", + "Background Music": "Âm Nhạc Nền", + "No Background Music": "Không Có Âm Nhạc Nền", + "Random Background Music": "Âm Nhạc Nền Ngẫu Nhiên", + "Custom Background Music": "Âm Nhạc Nền Tùy Chỉnh", + "Custom Background Music File": "Vui lòng nhập đường dẫn tệp cho âm nhạc nền tùy chỉnh:", + "Background Music Volume": "Âm Lượng Âm Nhạc Nền (0.2 đại diện cho 20%, âm nhạc nền không nên quá to)", + "Subtitle Settings": "**Cài Đặt Phụ Đề**", + "Enable Subtitles": "Bật Phụ Đề (Nếu không chọn, các cài đặt dưới đây sẽ không có hiệu lực)", + "Font": "Phông Chữ Phụ Đề", + "Position": "Vị Trí Phụ Đề", + "Top": "Trên", + "Center": "Giữa", + "Bottom": "Dưới (Được Khuyến Nghị)", + "Custom": "Vị trí tùy chỉnh (70, chỉ ra là cách đầu trang 70%)", + "Font Size": "Cỡ Chữ Phụ Đề", + "Font Color": "Màu Chữ Phụ Đề", + "Stroke Color": "Màu Viền Phụ Đề", + "Stroke Width": "Độ Rộng Viền Phụ Đề", + "Generate Video": "Tạo Video", + "Video Script and Subject Cannot Both Be Empty": "Chủ Đề Video và Kịch Bản Video không thể cùng trống", + "Generating Video": "Đang tạo video, vui lòng đợi...", + "Start Generating Video": "Bắt Đầu Tạo Video", + "Video Generation Completed": "Hoàn Tất Tạo Video", + "Video Generation Failed": "Tạo Video Thất Bại", + "You can download the generated video from the following links": "Bạn có thể tải video được tạo ra từ các liên kết sau", + "Basic Settings": "**Cài Đặt Cơ Bản** (:blue[Nhấp để mở rộng])", + "Language": "Ngôn Ngữ", + "Pexels API Key": "Khóa API Pexels ([Lấy Khóa API](https://www.pexels.com/api/))", + "Pixabay API Key": "Khóa API Pixabay ([Lấy Khóa API](https://pixabay.com/api/docs/#api_search_videos))", + "LLM Provider": "Nhà Cung Cấp LLM", + "API Key": "Khóa API (:red[Bắt Buộc])", + "Base Url": "Url Cơ Bản", + "Account ID": "ID Tài Khoản (Lấy từ bảng điều khiển Cloudflare)", + "Model Name": "Tên Mô Hình", + "Please Enter the LLM API Key": "Vui lòng Nhập **Khóa API LLM**", + "Please Enter the Pexels API Key": "Vui lòng Nhập **Khóa API Pexels**", + "Please Enter the Pixabay API Key": "Vui lòng Nhập **Khóa API Pixabay**", + "Get Help": "Nếu bạn cần giúp đỡ hoặc có bất kỳ câu hỏi nào, bạn có thể tham gia discord để được giúp đỡ: https://harryai.cc", + "Video Source": "Nguồn Video", + "TikTok": "TikTok (Hỗ trợ TikTok sắp ra mắt)", + "Bilibili": "Bilibili (Hỗ trợ Bilibili sắp ra mắt)", + "Xiaohongshu": "Xiaohongshu (Hỗ trợ Xiaohongshu sắp ra mắt)", + "Local file": "Tệp cục bộ", + "Play Voice": "Phát Giọng Nói", + "Voice Example": "Đây là văn bản mẫu để kiểm tra tổng hợp giọng nói", + "Synthesizing Voice": "Đang tổng hợp giọng nói, vui lòng đợi...", + "TTS Provider": "Chọn nhà cung cấp tổng hợp giọng nói", + "TTS Servers": "Máy chủ TTS", + "No voices available for the selected TTS server. Please select another server.": "Không có giọng nói nào cho máy chủ TTS đã chọn. Vui lòng chọn máy chủ khác.", + "SiliconFlow API Key": "Khóa API SiliconFlow", + "SiliconFlow TTS Settings": "Cài đặt SiliconFlow TTS", + "Speed: Range [0.25, 4.0], default is 1.0": "Tốc độ: Phạm vi [0.25, 4.0], mặc định là 1.0", + "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "Âm lượng: Sử dụng cài đặt Âm lượng Giọng nói, mặc định 1.0 tương ứng với tăng ích 0", + "Hide Log": "Ẩn Nhật Ký", + "Hide Basic Settings": "Ẩn Cài Đặt Cơ Bản\n\nẨn, thanh cài đặt cơ bản sẽ không hiển thị trên trang web.\n\nNếu bạn muốn hiển thị lại, vui lòng đặt `hide_config = false` trong `config.toml`", + "LLM Settings": "**Cài Đặt LLM**", + "Video Source Settings": "**Cài Đặt Nguồn Video**", + "Click to show API Key management": "Nhấn để hiển thị quản lý API Key", + "Manage Pexels and Pixabay API Keys": "Quản lý API Key của Pexels và Pixabay", + "Current Keys:": "Các Key hiện tại:", + "No Pexels API Keys currently": "Hiện không có API Key Pexels", + "Add Pexels API Key": "Thêm API Key Pexels", + "Pexels API Key added successfully": "Thêm API Key Pexels thành công", + "This API Key already exists": "API Key này đã tồn tại", + "Please enter a valid API Key": "Vui lòng nhập API Key hợp lệ", + "Select Pexels API Key to delete": "Chọn API Key Pexels để xóa", + "Delete Selected Pexels API Key": "Xóa API Key Pexels đã chọn", + "Pexels API Key deleted successfully": "Xóa API Key Pexels thành công", + "No Pixabay API Keys currently": "Hiện không có API Key Pixabay", + "Add Pixabay API Key": "Thêm API Key Pixabay", + "Pixabay API Key added successfully": "Thêm API Key Pixabay thành công", + "Select Pixabay API Key to delete": "Chọn API Key Pixabay để xóa", + "Delete Selected Pixabay API Key": "Xóa API Key Pixabay đã chọn", + "Pixabay API Key deleted successfully": "Xóa API Key Pixabay thành công" + } +} \ No newline at end of file diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json new file mode 100644 index 0000000..1f8f66a --- /dev/null +++ b/webui/i18n/zh.json @@ -0,0 +1,135 @@ +{ + "Language": "简体中文", + "Translation": { + "Login Required": "需要登录", + "Please login to access settings": "请登录后访问配置设置 (:gray[默认用户名: admin, 密码: admin, 您可以在 config.toml 中修改])", + "Username": "用户名", + "Password": "密码", + "Login": "登录", + "Login Error": "登录错误", + "Incorrect username or password": "用户名或密码不正确", + "Please enter your username and password": "请输入用户名和密码", + "Video Script Settings": "**文案设置**", + "Video Subject": "视频主题(给定一个关键词,:red[AI自动生成]视频文案)", + "Script Language": "生成视频脚本的语言(一般情况AI会自动根据你输入的主题语言输出)", + "Advanced Script Settings": "高级脚本设置", + "Script Paragraph Number": "文案段落数量", + "Custom Script Requirements": "自定义文案要求", + "Custom Script Requirements Placeholder": "例如:语气更轻松,适合小红书风格,面向年轻用户,开头更有悬念", + "Use Custom System Prompt": "使用自定义系统提示词", + "Use Custom System Prompt Help": "启用后会覆盖默认脚本生成规则,适合熟悉 Prompt 的高级用户。", + "Custom System Prompt": "自定义系统提示词", + "Generate Video Script and Keywords": "点击使用AI根据**主题**生成 【视频文案】 和 【视频关键词】", + "Auto Detect": "自动检测", + "Video Script": "视频文案(:blue[①可不填,使用AI生成 ②合理使用标点断句,有助于生成字幕])", + "Generate Video Keywords": "点击使用AI根据**文案**生成【视频关键词】", + "Please Enter the Video Subject": "请先填写视频文案", + "Generating Video Script and Keywords": "AI正在生成视频文案和关键词...", + "Generating Video Keywords": "AI正在生成视频关键词...", + "Video Keywords": "视频关键词(:blue[①可不填,使用AI生成 ②用**英文逗号**分隔,只支持英文])", + "Video Settings": "**视频设置**", + "Video Concat Mode": "视频拼接模式", + "Random": "随机拼接(推荐)", + "Sequential": "顺序拼接", + "Video Transition Mode": "视频转场模式", + "None": "无转场", + "Shuffle": "随机转场", + "FadeIn": "渐入", + "FadeOut": "渐出", + "SlideIn": "滑动入", + "SlideOut": "滑动出", + "Video Ratio": "视频比例", + "Portrait": "竖屏 9:16(抖音视频)", + "Landscape": "横屏 16:9(西瓜视频)", + "Clip Duration": "视频片段最大时长(秒)(**不是视频总长度**,是指每个**合成片段**的长度)", + "Number of Videos Generated Simultaneously": "同时生成视频数量", + "Audio Settings": "**音频设置**", + "Speech Synthesis": "朗读声音(:red[**与文案语言保持一致**。注意:V2版效果更好,但是需要API KEY])", + "Speech Region": "服务区域 (:red[必填,[点击获取](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Key": "API Key (:red[必填,密钥1 或 密钥2 均可 [点击获取](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/SpeechServices)])", + "Speech Volume": "朗读音量(1.0表示100%)", + "Speech Rate": "朗读速度(1.0表示1倍速)", + "Custom Audio File": "自定义音频文件", + "Custom audio will be used directly. TTS synthesis will be skipped for this task.": "将直接使用自定义音频,本次任务会跳过 TTS 语音合成。", + "Male": "男性", + "Female": "女性", + "Background Music": "背景音乐", + "No Background Music": "无背景音乐", + "Random Background Music": "随机背景音乐", + "Custom Background Music": "自定义背景音乐", + "Custom Background Music File": "请输入自定义背景音乐的文件路径", + "Background Music Volume": "背景音乐音量(0.2表示20%,背景声音不宜过高)", + "Subtitle Settings": "**字幕设置**", + "Enable Subtitles": "启用字幕(若取消勾选,下面的设置都将不生效)", + "Font": "字幕字体", + "Position": "字幕位置", + "Top": "顶部", + "Center": "中间", + "Bottom": "底部(推荐)", + "Custom": "自定义位置(70,表示离顶部70%的位置)", + "Font Size": "字幕大小", + "Font Color": "字幕颜色", + "Stroke Color": "描边颜色", + "Stroke Width": "描边粗细", + "Generate Video": "生成视频", + "Video Script and Subject Cannot Both Be Empty": "视频主题 和 视频文案,不能同时为空", + "Generating Video": "正在生成视频,请稍候...", + "Start Generating Video": "开始生成视频", + "Video Generation Completed": "视频生成完成", + "Video Generation Failed": "视频生成失败", + "You can download the generated video from the following links": "你可以从以下链接下载生成的视频", + "Basic Settings": "**基础设置** (:blue[点击展开])", + "Language": "界面语言", + "Pexels API Key": "Pexels API Key ([点击获取](https://www.pexels.com/api/)) :red[推荐使用]", + "Pixabay API Key": "Pixabay API Key ([点击获取](https://pixabay.com/api/docs/#api_search_videos)) :red[可以不用配置,如果 Pexels 无法使用,再选择Pixabay]", + "LLM Provider": "大模型提供商", + "API Key": "API Key (:red[必填,需要到大模型提供商的后台申请])", + "Base Url": "Base Url (可选)", + "Account ID": "账户ID (Cloudflare的dash面板url中获取)", + "Model Name": "模型名称 (:blue[需要到大模型提供商的后台确认被授权的模型名称])", + "Please Enter the LLM API Key": "请先填写大模型 **API Key**", + "Please Enter the Pexels API Key": "请先填写 **Pexels API Key**", + "Please Enter the Pixabay API Key": "请先填写 **Pixabay API Key**", + "Get Help": "有任何问题或建议,可以加入 **微信群** 求助或讨论:https://harryai.cc", + "Video Source": "视频来源", + "TikTok": "抖音 (TikTok 支持中,敬请期待)", + "Bilibili": "哔哩哔哩 (Bilibili 支持中,敬请期待)", + "Xiaohongshu": "小红书 (Xiaohongshu 支持中,敬请期待)", + "Local file": "本地文件", + "Play Voice": "试听语音合成", + "Voice Example": "这是一段测试语音合成的示例文本", + "Synthesizing Voice": "语音合成中,请稍候...", + "TTS Provider": "语音合成提供商", + "TTS Servers": "TTS服务器", + "No voices available for the selected TTS server. Please select another server.": "当前选择的TTS服务器没有可用的声音,请选择其他服务器。", + "SiliconFlow API Key": "硅基流动API密钥 [点击获取](https://cloud.siliconflow.cn/account/ak)", + "SiliconFlow TTS Settings": "硅基流动TTS设置", + "Speed: Range [0.25, 4.0], default is 1.0": "语速范围 [0.25, 4.0],默认值为1.0", + "Volume: Uses Speech Volume setting, default 1.0 maps to gain 0": "音量:使用朗读音量设置,默认值1.0对应增益0", + "MiMo API Key": "小米 MiMo API 密钥 [点击获取](https://platform.xiaomimimo.com/docs/zh-CN/quick-start/first-api-call)", + "MiMo TTS Settings": "小米 MiMo TTS 设置", + "Uses Xiaomi MiMo V2.5 TTS preset voices": "使用小米 MiMo V2.5 TTS 预置音色", + "Speed and volume are currently handled by the provider defaults": "语速和音量当前使用服务商默认效果", + "Hide Log": "隐藏日志", + "Hide Basic Settings": "隐藏基础设置\n\n隐藏后,基础设置面板将不会显示在页面中。\n\n如需要再次显示,请在 `config.toml` 中设置 `hide_config = false`", + "LLM Settings": "**大模型设置**", + "Video Source Settings": "**视频源设置**", + "Click to show API Key management": "点击以显示 API Key 管理功能", + "Manage Pexels and Pixabay API Keys": "管理 Pexels 和 Pixabay API Keys", + "Current Keys:": "当前 Keys:", + "No Pexels API Keys currently": "暂无 Pexels API Keys", + "Add Pexels API Key": "新增 Pexels API Key", + "Pexels API Key added successfully": "Pexels API Key 添加成功", + "This API Key already exists": "这个 Key 已经存在", + "Please enter a valid API Key": "请输入一个有效的 Key", + "Select Pexels API Key to delete": "选择要删除的 Pexels API Key", + "Delete Selected Pexels API Key": "删除选定的 Pexels API Key", + "Pexels API Key deleted successfully": "Pexels API Key 删除成功", + "No Pixabay API Keys currently": "暂无 Pixabay API Keys", + "Add Pixabay API Key": "新增 Pixabay API Key", + "Pixabay API Key added successfully": "Pixabay API Key 添加成功", + "Select Pixabay API Key to delete": "选择要删除的 Pixabay API Key", + "Delete Selected Pixabay API Key": "删除选定的 Pixabay API Key", + "Pixabay API Key deleted successfully": "Pixabay API Key 删除成功" + } +}