diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..df67e1a21093abaa3f144320518898d37b19088b --- /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/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000000000000000000000000000000000..3e0e95f36854ecedfffdfb05449fa46f2586dbb0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,87 @@ +name: 🐛 Bug | Bug Report +description: 报告错误或异常问题 | Report an error or unexpected behavior +title: "[Bug]: " +labels: + - bug + +body: + - type: markdown + attributes: + value: | + **提交问题前,请确保您已阅读以下文档:[Getting Started (English)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md#system-requirements-) 或 [快速开始 (中文)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README.md#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B-)。** + + **Before submitting an issue, please make sure you've read the following documentation: [Getting Started (English)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README-en.md#system-requirements-) or [快速开始 (Chinese)](https://github.com/harry0703/MoneyPrinterTurbo/blob/main/README.md#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B-).** + + - type: textarea + attributes: + label: 问题描述 | Current Behavior + description: | + 描述您遇到的问题 + Describe the issue you're experiencing + placeholder: | + 当我执行...操作时,程序出现了...问题 + When I perform..., the program shows... + validations: + required: true + - type: textarea + attributes: + label: 重现步骤 | Steps to Reproduce + description: | + 详细描述如何重现此问题 + Describe in detail how to reproduce this issue + placeholder: | + 1. 打开... + 2. 点击... + 3. 出现错误... + + 1. Open... + 2. Click on... + 3. Error occurs... + validations: + required: true + - type: textarea + attributes: + label: 错误日志 | Error Logs + description: | + 请提供相关错误信息或日志(注意不要包含敏感信息) + Please provide any error messages or logs (be careful not to include sensitive information) + placeholder: | + 错误信息、日志或截图... + Error messages, logs, or screenshots... + validations: + required: true + - type: input + attributes: + label: Python 版本 | Python Version + description: | + 您使用的 Python 版本 + The Python version you're using + placeholder: v3.13.0, v3.10.0, etc. + validations: + required: true + - type: input + attributes: + label: 操作系统 | Operating System + description: | + 您的操作系统信息 + Your operating system information + placeholder: macOS 14.1, Windows 11, Ubuntu 22.04, etc. + validations: + required: true + - type: input + attributes: + label: MoneyPrinterTurbo 版本 | Version + description: | + 您使用的 MoneyPrinterTurbo 版本 + The version of MoneyPrinterTurbo you're using + placeholder: v1.2.2, etc. + validations: + required: true + - type: textarea + attributes: + label: 补充信息 | Additional Information + description: | + 其他对解决问题有帮助的信息(如截图、视频等) + Any other information that might help solve the issue (screenshots, videos, etc.) + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..ec4bb386bcf8a4946923eff961cc7cdf70c0aedf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000000000000000000000000000000000..abd304595133d589e75f75a8ccc3216d5b5e050b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: ✨ 增加功能 | Feature Request +description: 为此项目提出一个新想法或建议 | Suggest a new idea for this project +title: "[Feature]: " +labels: + - enhancement + +body: + - type: textarea + attributes: + label: 需求描述 | Problem Statement + description: | + 请描述您希望解决的问题或需求 + Please describe the problem you want to solve + placeholder: | + 我在使用过程中遇到了... + I encountered... when using this project + validations: + required: true + - type: textarea + attributes: + label: 建议的解决方案 | Proposed Solution + description: | + 请描述您认为可行的解决方案或实现方式 + Please describe your suggested solution or implementation + placeholder: | + 可以考虑添加...功能来解决这个问题 + Consider adding... feature to address this issue + validations: + required: true \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000000000000000000000000000000000000..dd7135eb30e8cb845361adc27e9291ab54777f42 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Supported Versions + +Security fixes are applied on a best-effort basis to the latest `main` branch and the most recent published release line. + +## Reporting a Vulnerability + +Please do **not** disclose suspected vulnerabilities in public GitHub issues. + +Preferred process: + +1. Use GitHub private vulnerability reporting for this repository if it is available in the repository security settings. +2. If private reporting is not available, open a minimal public issue that only requests a private contact channel and does **not** include vulnerability details, proof-of-concept code, payloads, or sensitive file paths. +3. Wait for a maintainer response before sharing any technical details publicly. + +When reporting a vulnerability privately, include: + +- affected commit, tag, or release version +- attack surface or vulnerable endpoint +- impact summary +- reproduction conditions +- suggested remediation, if available + +## Disclosure Expectations + +- Please give maintainers reasonable time to investigate and prepare a fix before public disclosure. +- Once a fix is available, coordinated public disclosure is welcome. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ed9f991b929287ff17a5f42e321be901f4c2a9a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +.DS_Store +/config.toml +/storage/ +/.idea/ +/app/services/__pycache__ +/app/__pycache__/ +/app/config/__pycache__/ +/app/models/__pycache__/ +/app/utils/__pycache__/ +/*/__pycache__/* +.vscode +/**/.streamlit +__pycache__ +logs/ + +node_modules +# VuePress 默认临时文件目录 +/sites/docs/.vuepress/.temp +# VuePress 默认缓存目录 +/sites/docs/.vuepress/.cache +# VuePress 默认构建生成的静态文件目录 +/sites/docs/.vuepress/dist +# 模型目录 +/models/ +./models/* + +venv/ +.venv + +# Debug and test files +CLAUDE.md +debug/ +debug_*.py +test_*.py +!test/services/test_*.py +streamlit.log diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000000000000000000000000000000000..2c0733315e415bfb5e5b353f9996ecd964d395b2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7813486933e863e89876c6f25ee8b6da51373292 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM python:3.10-slim + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PORT=7860 + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + imagemagick \ + curl \ + git \ + build-essential \ + fonts-wqy-zenhei \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Fix ImageMagick policy to allow MoviePy to render subtitles and text clips +RUN sed -i 's/rights="none" pattern="@\*"/rights="read|write" pattern="@*"/' /etc/ImageMagick-6/policy.xml || true + +# Set up working directory +WORKDIR /app + +# Copy requirements and install +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application files (including pre-built static assets in resource/public) +COPY . /app + +# Create necessary directories and ensure correct permissions +RUN mkdir -p /app/storage/tasks /app/storage/cache_videos /app/resource/public /app/resource/songs /app/resource/fonts && \ + ln -s /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/resource/fonts/STHeitiMedium.ttc && \ + ln -s /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/resource/fonts/STHeitiLight.ttc && \ + ln -s /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/resource/fonts/MicrosoftYaHeiNormal.ttc && \ + ln -s /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc /app/resource/fonts/MicrosoftYaHeiBold.ttc && \ + chmod -R 777 /app/storage /app/resource + +# Expose port +EXPOSE 7860 + +# Command to run the application +CMD ["python", "main.py"] \ No newline at end of file diff --git a/Dockerfile.gpu b/Dockerfile.gpu new file mode 100644 index 0000000000000000000000000000000000000000..3f48af37d0872692337270923d32530837806b3f --- /dev/null +++ b/Dockerfile.gpu @@ -0,0 +1,55 @@ +# Use NVIDIA CUDA runtime as parent image (includes CUDA 12.1 + cuDNN 8) +FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 + +# Avoid interactive timezone prompt +ENV DEBIAN_FRONTEND=noninteractive + +# Set the working directory in the container +WORKDIR /MoneyPrinterTurbo +RUN chmod 777 /MoneyPrinterTurbo + +ENV PYTHONPATH="/MoneyPrinterTurbo" + +# Install Python 3.11 and system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + software-properties-common \ + git \ + imagemagick \ + ffmpeg \ + curl \ + && add-apt-repository ppa:deadsnakes/ppa \ + && apt-get update && apt-get install -y --no-install-recommends \ + python3.11 \ + python3.11-venv \ + python3.11-dev \ + python3-pip \ + && ln -sf /usr/bin/python3.11 /usr/bin/python3 \ + && ln -sf /usr/bin/python3.11 /usr/bin/python \ + && python3.11 -m pip install --upgrade pip \ + && apt-get remove -y python3-blinker || true \ + && rm -rf /var/lib/apt/lists/* + +# Fix security policy for ImageMagick +RUN sed -i '/ +

MoneyPrinterTurbo 💸

+ +

+ Stargazers + Issues + Forks + License +

+ +

العربية | English | 简体中文

+ +
+ harry0703%2FMoneyPrinterTurbo | Trendshift +
+ +ما عليك سوى تقديم موضوع أو كلمة مفتاحية للفيديو، وسيقوم التطبيق تلقائياً بتوليد نص الفيديو، +ومواد الفيديو، والترجمة، وموسيقى الخلفية، ثم تركيبها في فيديو قصير عالي الدقة. + +### واجهة الويب (WebUI) + +![](docs/webui-en.jpg) + +### واجهة الـ API + +![](docs/api.jpg) + + + +## المميزات 🎯 + +- [x] بنية **MVC** كاملة، وكود **واضح التنظيم** وسهل الصيانة، يدعم كلاً من `API` و`واجهة الويب` +- [x] يدعم **توليد نص الفيديو بالذكاء الاصطناعي**، إضافةً إلى **النص المخصّص** +- [x] يدعم أحجام **فيديو عالي الدقة** متنوعة + - [x] عمودي 9:16، `1080x1920` + - [x] أفقي 16:9، `1920x1080` +- [x] يدعم **توليد الفيديو دفعةً واحدة**، فيمكن إنشاء عدة فيديوهات معاً ثم اختيار الأفضل +- [x] يدعم ضبط **مدة مقاطع الفيديو**، مما يسهّل التحكم في تكرار تبديل المواد +- [x] يدعم نص الفيديو بكل من **الصينية** و**الإنجليزية** +- [x] يدعم **تركيب أصوات متعددة**، مع **معاينة فورية** للنتيجة +- [x] يدعم **توليد الترجمة**، مع إمكانية ضبط `الخط` و`الموضع` و`اللون` و`الحجم`، كما يدعم `تحديد إطار الترجمة` +- [x] يدعم **موسيقى الخلفية**، إما عشوائية أو ملفات موسيقى محدّدة، مع إمكانية ضبط `مستوى صوت موسيقى الخلفية` +- [x] مصادر مواد الفيديو **عالية الدقة** و**خالية من حقوق الملكية**، كما يمكنك استخدام **موادك المحلية** الخاصة +- [x] يدعم التكامل مع نماذج متعددة مثل **OpenAI** و**Moonshot** و**Azure** و**gpt4free** و**one-api** و**Qwen** و**Google Gemini** و**Ollama** و**DeepSeek** و**MiniMax** و**ERNIE** و**Pollinations** و**ModelScope** وغيرها + +## عروض فيديو توضيحية 📺 + +### عمودي 9:16 + + + + + + + + + + + + + + +
▶️ كيف تضيف المتعة إلى حياتك ▶️ ما معنى الحياة
+ +### أفقي 16:9 + + + + + + + + + + + + + + +
▶️ ما معنى الحياة▶️ لماذا تمارس الرياضة
+ +## متطلبات النظام 📦 + +- المنصّات المُوصى بها: Windows 10+ أو macOS 11+ أو توزيعة Linux رئيسية +- وجود كرت رسومات (GPU) ليس ضرورياً، لكنه مُستحسَن إن أردت نسخاً صوتياً محلياً أسرع، أو معالجة فيديو أسرع، أو توليداً دفعياً أكثر سلاسة + +| العنصر | الحد الأدنى | المُوصى به | الأمثل | +| --- | --- | --- | --- | +| المعالج (CPU) | 4 أنوية | 6 إلى 8 أنوية | 8+ أنوية | +| الذاكرة (RAM) | 4 GB | 8 GB | 16+ GB | +| كرت الرسومات (GPU) | غير مطلوب | 4+ GB VRAM | 8+ GB VRAM | + +- إذا كنت تعتمد أساساً على نماذج LLM السحابية، وخدمات TTS السحابية، ومصادر المواد عبر الإنترنت، فإن المعالج والذاكرة أهم من كرت الرسومات +- إذا كنت تستخدم `faster-whisper` أو التوليد الدفعي أو المعالجة المحلية الثقيلة، فسيحسّن كرت الرسومات الإنتاجية بشكل ملحوظ + +## البدء السريع 🚀 + +### المسارات المُوصى بها + +- مستخدمو Windows: استخدم الحزمة الجاهزة بنقرة واحدة أولاً للتجربة المحلية الأسرع +- مستخدمو MacOS / Linux: استخدم `uv sync --frozen` كمسار الإعداد المحلي الأساسي +- إذا أردت بيئة تشغيل أكثر عزلاً: استخدم النشر عبر Docker + +### التشغيل في Google Colab +تريد تجربة MoneyPrinterTurbo دون إعداد بيئة محلية؟ شغّله مباشرةً في 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 + +الحزمة القابلة للتنزيل ما زالت بناء `v1.2.6` القديم المُجمّع. بعد التنزيل، شغّل `update.bat` أولاً لتحديثه إلى أحدث كود. + +Google Drive (v1.2.6): https://drive.google.com/file/d/1HsbzfT7XunkrCrHw5ncUjFX8XX4zAuUh/view?usp=sharing + +بعد التنزيل، يُنصح بالنقر المزدوج على `update.bat` أولاً للتحديث إلى **أحدث كود**، ثم النقر المزدوج على `start.bat` للتشغيل + +بعد التشغيل، سيُفتح المتصفح تلقائياً (إن فُتح فارغاً، يُنصح باستخدام **Chrome** أو **Edge**) + +### الأنظمة الأخرى + +لم تُنشأ حزم تشغيل بنقرة واحدة بعد. راجع قسم **التثبيت والنشر** أدناه. يُنصح باستخدام **docker** للنشر لأنه أكثر سهولة. + +## التثبيت والنشر 📥 + +### المتطلبات المُسبقة + +#### ① استنساخ المشروع + +```shell +git clone https://github.com/harry0703/MoneyPrinterTurbo.git +``` + +#### ② تعديل ملف الإعدادات + +- انسخ ملف `config.example.toml` وأعد تسميته إلى `config.toml` +- اتبع التعليمات داخل ملف `config.toml` لضبط `pexels_api_keys` و`llm_provider`، وبحسب مزوّد خدمة الـ llm_provider، اضبط مفتاح الـ API المقابل + +### النشر عبر Docker 🐳 + +#### ① تشغيل حاوية Docker + +إذا لم تكن قد ثبّت Docker، فثبّته أولاً https://www.docker.com/products/docker-desktop/ +إذا كنت تستخدم نظام Windows، فراجع وثائق Microsoft: + +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 +``` + +> ملاحظة: أحدث إصدار من docker يثبّت docker compose تلقائياً على هيئة إضافة (plug-in)، ويتغيّر أمر التشغيل إلى `docker compose up` + +#### ② الوصول إلى واجهة الويب + +افتح متصفحك وزر http://127.0.0.1:8501 + +#### ③ الوصول إلى واجهة الـ API + +افتح متصفحك وزر http://0.0.0.0:8080/docs أو http://0.0.0.0:8080/redoc + +### النشر اليدوي 📦 + +#### ① إنشاء بيئة Python افتراضية + +يُنصح باستخدام [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، وتأكد من اختيار نسخة **المكتبة الساكنة (static library)**، مثل 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 +``` + +#### ③ تشغيل واجهة الويب 🌐 + +لاحظ أنك بحاجة لتنفيذ الأوامر التالية في `المجلد الجذر` لمشروع MoneyPrinterTurbo + +###### Windows + +```shell +uv run streamlit run ./webui/Main.py --browser.gatherUsageStats=False +``` + +إذا كنت قد فعّلت البيئة الافتراضية يدوياً، فما زال بإمكانك تشغيل: + +```bat +webui.bat +``` + +###### MacOS أو Linux + +```shell +uv run streamlit run ./webui/Main.py --browser.gatherUsageStats=False +``` + +إذا كنت قد فعّلت البيئة الافتراضية يدوياً، فما زال بإمكانك تشغيل: + +```shell +sh webui.sh +``` + +بعد التشغيل، سيُفتح المتصفح تلقائياً + +#### ④ تشغيل خدمة الـ API 🚀 + +```shell +uv run python main.py +``` + +إذا كنت قد فعّلت البيئة الافتراضية يدوياً، فما زال بإمكانك تشغيل: + +```shell +python main.py +``` + +## شكر خاص 🙏 + +نظراً لأن **نشر** و**استخدام** هذا المشروع يمثّل عتبةً معينة لبعض المستخدمين المبتدئين، نودّ أن نتقدّم بشكر خاص إلى + +**RecCloud (منصة خدمات وسائط متعددة مدعومة بالذكاء الاصطناعي)** لتقديمها خدمة `AI Video Generator` مجانية مبنية على هذا +المشروع. فهي تتيح الاستخدام عبر الإنترنت دون نشر، وهو أمر مريح للغاية. + +- النسخة الصينية: https://reccloud.cn +- النسخة الإنجليزية: https://reccloud.com + +![](docs/reccloud.com.jpg) + +## شكراً للرعاية 🙏 + +شكراً لـ Picwish https://picwish.com على دعمها ورعايتها لهذا المشروع، مما يتيح التحديث والصيانة المستمرّين. + +تركّز Picwish على **مجال معالجة الصور**، وتوفّر مجموعة غنية من **أدوات معالجة الصور** التي تبسّط العمليات المعقّدة إلى حدٍّ بعيد، فتجعل معالجة الصور أسهل حقاً. + +![picwish.jpg](docs/picwish.com.jpg) + +بعد التشغيل، يمكنك عرض `وثائق الـ API` على http://127.0.0.1:8080/docs واختبار الواجهة مباشرةً عبر الإنترنت +لتجربة سريعة. + +## تركيب الصوت 🗣 + +يمكن عرض قائمة بجميع الأصوات المدعومة هنا: [قائمة الأصوات](./docs/voice-list.txt) + +2024-04-16 v1.1.2 أُضيفت 9 أصوات تركيب صوتي جديدة من Azure تتطلب ضبط مفتاح API. هذه الأصوات تبدو أكثر واقعية. + +## توليد الترجمة 📜 + +حالياً، هناك طريقتان لتوليد الترجمة: + +- **edge**: سرعة توليد أعلى، وأداء أفضل، ولا متطلبات خاصة لمواصفات الحاسوب، لكن الجودة قد تكون غير مستقرة +- **whisper**: سرعة توليد أبطأ، وأداء أضعف، ومتطلبات خاصة لمواصفات الحاسوب، لكن الجودة أكثر موثوقية + +يمكنك التبديل بينهما بتعديل `subtitle_provider` في ملف الإعدادات `config.toml` + +يُنصح باستخدام وضع `edge`، والتبديل إلى وضع `whisper` إذا كانت جودة الترجمة المُولّدة غير مُرضية. + +> ملاحظة: +> +> 1. في وضع whisper، تحتاج إلى تنزيل ملف نموذج من HuggingFace بحجم نحو 3GB، فتأكد من اتصال إنترنت جيد +> 2. إذا تُرك فارغاً، فهذا يعني أنه لن تُولَّد أي ترجمة. + +> بما أن HuggingFace غير متاح في الصين، يمكنك استخدام الطرق التالية لتنزيل ملف نموذج `whisper-large-v3` + +روابط التنزيل: + +- Baidu Netdisk: https://pan.baidu.com/s/11h3Q6tsDtjQKTjUu3sc5cA?pwd=xjs9 +- Quark Netdisk: 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. +``` + +في هذه الحالة، يمكنك تنزيل ffmpeg من https://www.gyan.dev/ffmpeg/builds/ ثم فك ضغطه وضبط `ffmpeg_path` على مسار +التثبيت الفعلي لديك. + +```toml +[app] +# الرجاء الضبط بحسب مسارك الفعلي، ولاحظ أن فاصل المسارات في Windows هو \\ +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. اتبع `عنوان التنزيل` الموجود في `الإعداد النموذجي` + لتثبيت https://imagemagick.org/archive/binaries/ImageMagick-7.1.1-30-Q16-x64-static.exe (باستخدام المكتبة الساكنة) +2. لا تثبّت في مسار يحتوي على أحرف صينية لتجنّب مشكلات غير متوقّعة + +[issue 54](https://github.com/harry0703/MoneyPrinterTurbo/issues/54#issuecomment-2017842022) + +لأنظمة Linux، يمكنك تثبيته يدوياً، راجع https://cn.linux-console.net/?p=16978 + +شكراً لـ [@wangwenqiao666](https://github.com/wangwenqiao666) على بحثه واستكشافه + +### ❓ImageMagick's security policy prevents operations related to temporary file @/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 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. + +أو + +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. + +الحل: [اضغط لمعرفة كيفية تنزيل النموذج يدوياً من قرص الشبكة](#توليد-الترجمة-) + +## الملاحظات والاقتراحات 📢 + +- يمكنك إرسال [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/README-en.md b/README-en.md new file mode 100644 index 0000000000000000000000000000000000000000..be5773e6c740bcaf033013bac2408cedba2913ce --- /dev/null +++ b/README-en.md @@ -0,0 +1,433 @@ +
+

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) + +
+ +

+ + Thanks to AIHubMix for sponsoring this project. AIHubMix deeply adapts to OpenAI, Claude, Gemini, DeepSeek, Zhipu, Qwen, and other leading models, providing one-stop access to GPT-5.5, deepseek-v4-flash, and 700+ models including free options with production-grade stability. + +

+ +## 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**, **AIHubMix**, **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 +- To use the recommended multi-model provider, you can set `llm_provider` to `aihubmix` and enter 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 index af87929546f51adfebf85711493b49357151cd3e..13d149f618c8e841be307c0b717ef675b99aa270 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,419 @@ --- -title: Money Printer Studio -emoji: 👀 +title: MoneyPrinterTurbo Swarm +emoji: 🤖🎬 colorFrom: indigo -colorTo: indigo +colorTo: green sdk: docker -pinned: false -license: mit -short_description: money printer turbo (Short Form Video) +app_port: 7860 --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +
+

MoneyPrinterTurbo 💸

+ +

+ Stargazers + Issues + Forks + License +

+
+

简体中文 | English | العربية

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

+ + 感谢 AIHubMix 对本项目的赞助。AIHubMix 深度适配 OpenAI、Claude、Gemini、DeepSeek、智谱、千问等全球顶级最新模型,一站式快速接入 GPT-5.5、deepseek-v4-flash 等 700+ 模型(含多个免费模型),提供企业级生产稳定性保障。 + +

+ +

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**、**AIHubMix**、**Moonshot**、**Azure**、**gpt4free**、**one-api**、**通义千问**、**Google Gemini**、**Ollama**、**DeepSeek**、**MiniMax**、 **文心一言**, **Pollinations**、**ModelScope** 等多种模型接入 + +## 视频演示 📺 + +### 竖屏 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 +- 如果希望使用推荐的大模型平台,也可以将 `llm_provider` 设置为 `aihubmix`,并填写对应的 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/asgi.py b/app/asgi.py new file mode 100644 index 0000000000000000000000000000000000000000..01f8b7da6f1cc2c3c95d91737192c2801808e5fc --- /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 0000000000000000000000000000000000000000..dd468128b7999ad261d03bf81a7753d5843a882e --- /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 0000000000000000000000000000000000000000..d7e8098d5caacd3530853f7988479fca1bd3cb1e --- /dev/null +++ b/app/config/config.py @@ -0,0 +1,207 @@ +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", 8080) +listen_port = int(os.getenv("PORT", listen_port)) +project_name = _cfg.get("project_name", "MoneyPrinterTurbo") +project_description = _cfg.get( + "project_description", + "https://github.com/harry0703/MoneyPrinterTurbo" + "
Supported by AIHubMix", +) +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")), +) + +# Overrides from Environment variables for Hugging Face Spaces / Docker deployments +app["openai_api_key"] = os.getenv("OPENAI_API_KEY", app.get("openai_api_key", "")) +app["openai_base_url"] = os.getenv("OPENAI_BASE_URL", app.get("openai_base_url", "https://openrouter.ai/api/v1")) +app["openai_model_name"] = os.getenv("OPENAI_MODEL_NAME", app.get("openai_model_name", "openai/gpt-4o-mini")) +app["nvidia_api_key"] = os.getenv("NVIDIA_API_KEY", app.get("nvidia_api_key", "")) +app["nvidia_image_model"] = os.getenv("NVIDIA_IMAGE_MODEL", app.get("nvidia_image_model", "stabilityai/stable-diffusion-xl-base-1.0")) + +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 0000000000000000000000000000000000000000..122e341e07529b720f6a422d57f1f7a4d80a6638 --- /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 0000000000000000000000000000000000000000..34804798eeea28ef96ca3498e475f980daaba7e6 --- /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 0000000000000000000000000000000000000000..4a2612bdf33d9db77800330ff25bfbc4a1cb60ab --- /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 0000000000000000000000000000000000000000..861af0f4c248a45a37229ac0c9662e0ab6890fd9 --- /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 0000000000000000000000000000000000000000..073247ba3448688814919082faec7339d4718d3f --- /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 0000000000000000000000000000000000000000..1336e479c92cb7e40c53cda8a114c6bb218eb2f4 --- /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 0000000000000000000000000000000000000000..177dde8861ac9ff694993fda3936fea62b7e5550 --- /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 0000000000000000000000000000000000000000..8d561c6330df505a8f49ae8a7765f7529654732d --- /dev/null +++ b/app/controllers/v1/video.py @@ -0,0 +1,400 @@ +import glob +import os +import pathlib +import shutil +from typing import Union + +from fastapi import BackgroundTasks, Depends, Path, Query, 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)}" + ) + +@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)): + 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/const.py b/app/models/const.py new file mode 100644 index 0000000000000000000000000000000000000000..b589274004cc7431b6a68d59d47ec94762cf0dfe --- /dev/null +++ b/app/models/const.py @@ -0,0 +1,30 @@ +PUNCTUATIONS = [ + "?", + ",", + ".", + "、", + ";", + ":", + "!", + "…", + "?", + ",", + "。", + "、", + ";", + ":", + "!", + "...", + # 阿拉伯语常用标点也应作为自然断句点,避免脚本文本和 edge-tts + # 返回的字幕停顿边界不一致,导致后续逐行匹配失败。 + "،", + "؛", + "؟", +] + +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 0000000000000000000000000000000000000000..d938cb5ac8d32cd082d87a4dffe679fd17626ecc --- /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 0000000000000000000000000000000000000000..a57c7f927713e3381bc29eb1d6d904be7a9a1596 --- /dev/null +++ b/app/models/schema.py @@ -0,0 +1,344 @@ +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 + rounded_subtitle_background: bool = False + + 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 + rounded_subtitle_background: bool = False + 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 0000000000000000000000000000000000000000..cf840374878235215f4f3560e65a844dc55506b2 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/data/azure_voices.json b/app/services/data/azure_voices.json new file mode 100644 index 0000000000000000000000000000000000000000..af27ec030bdecefa39f8b5236b008fb8b1808cea --- /dev/null +++ b/app/services/data/azure_voices.json @@ -0,0 +1,1326 @@ +[ + { + "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" + } +] \ No newline at end of file diff --git a/app/services/llm.py b/app/services/llm.py new file mode 100644 index 0000000000000000000000000000000000000000..60a566572783821ac77955750c8a34843ddb0499 --- /dev/null +++ b/app/services/llm.py @@ -0,0 +1,725 @@ +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 == "aihubmix": + api_key = config.app.get("aihubmix_api_key") + model_name = config.app.get("aihubmix_model_name") + base_url = config.app.get("aihubmix_base_url", "") + # AIHubMix 兼容 OpenAI Chat Completions 协议。这里使用独立 + # provider 保存合作方的默认网关和推荐模型,避免把推广链接、 + # 默认模型等合作配置混进普通 OpenAI provider,影响现有用户。 + if not base_url: + base_url = "https://aihubmix.com/v1" + if not model_name: + model_name = "gpt-5.4-mini" + 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 0000000000000000000000000000000000000000..8e5f69f02b1cae9828b764be1f471d2436780873 --- /dev/null +++ b/app/services/material.py @@ -0,0 +1,388 @@ +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 search_images_nvidia( + search_term: str, + minimum_duration: int, + video_aspect: VideoAspect = VideoAspect.portrait, +) -> List[MaterialInfo]: + api_key = config.app.get("nvidia_api_key") + if not api_key: + logger.error("nvidia_api_key is empty") + return [] + + url = "https://ai.api.nvidia.com/v1/genai/black-forest-labs/flux.1-dev" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "application/json" + } + + # stabilityai/stable-diffusion-xl and flux.1-dev support specific resolutions: + # 1024x1024, 1152x896, 1216x832, 1344x768, 1536x640, 896x1152, 832x1216, 768x1344, 640x1536 + # Let's use 896x1152 for portrait (approx 9:16) and 1152x896 for landscape (approx 16:9) + aspect = VideoAspect(video_aspect) + if aspect == VideoAspect.portrait: + width, height = 896, 1152 + else: + width, height = 1152, 896 + + payload = { + "prompt": search_term, + "cfg_scale": 5, + "mode": "base", + "steps": 25, + "seed": 0, + "samples": 1, + "width": width, + "height": height + } + + logger.info(f"Generating image via Nvidia API for: {search_term} ({width}x{height})") + try: + r = requests.post(url, json=payload, headers=headers, timeout=120) + logger.info(f"Nvidia API Response Status Code: {r.status_code}") + if r.status_code != 200: + logger.error(f"Nvidia API error: {r.text}") + return [] + + response = r.json() + if "artifacts" not in response or len(response["artifacts"]) == 0: + logger.error(f"Nvidia API failed: {response}") + return [] + + b64 = response["artifacts"][0]["base64"] + + import base64 + import uuid + from moviepy import ImageClip + + save_dir = utils.storage_dir("cache_videos") + if not os.path.exists(save_dir): + os.makedirs(save_dir) + + image_id = str(uuid.uuid4()) + image_path = f"{save_dir}/{image_id}.jpg" + video_path = f"{save_dir}/{image_id}.mp4" + + with open(image_path, "wb") as f: + f.write(base64.b64decode(b64)) + + # Convert image to video clip + clip = ImageClip(image_path).with_duration(minimum_duration) + clip.write_videofile(video_path, fps=30, codec="libx264", logger=None) + clip.close() + + item = MaterialInfo() + item.provider = "nvidia" + item.url = video_path + item.duration = minimum_duration + return [item] + + except Exception as e: + logger.error(f"Nvidia Image generation failed: {str(e)}") + + return [] + + +def save_video(video_url: str, save_dir: str = "") -> str: + if not str(video_url).startswith("http"): + return video_url + + 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 + elif source == "nvidia": + search_videos = search_images_nvidia + + 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 0000000000000000000000000000000000000000..3e6b98d46184c77bdd200d95a071fbd3074b229f --- /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 0000000000000000000000000000000000000000..1e78ea680a69a1145a29d61190e038e6b2cb5f7e --- /dev/null +++ b/app/services/subtitle.py @@ -0,0 +1,306 @@ +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) + normalized_script = utils.normalize_script_for_subtitle_matching(video_script) + script_lines = utils.split_string_by_punctuations(normalized_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 0000000000000000000000000000000000000000..ebb3a6d49e8e568292cacc61557e2745b38ac6f2 --- /dev/null +++ b/app/services/task.py @@ -0,0 +1,397 @@ +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"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 + 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 0000000000000000000000000000000000000000..2c0e71010819197b833c7657d5bac3e1f415f40e --- /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 0000000000000000000000000000000000000000..1a764347da9a7ccd5a9a3955d5381d6479da3877 --- /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 0000000000000000000000000000000000000000..43b326104c318f2fc300f5597f5b041f0aeee205 --- /dev/null +++ b/app/services/video.py @@ -0,0 +1,928 @@ +import glob +import itertools +import io +import os +import random +import gc +import shutil +import subprocess +from contextlib import redirect_stdout +from typing import List +from loguru import logger +import numpy as np +from moviepy import ( + AudioFileClip, + ColorClip, + CompositeAudioClip, + CompositeVideoClip, + ImageClip, + TextClip, + VideoFileClip, + afx, +) +from moviepy.video.tools.subtitles import SubtitlesClip +from PIL import Image, ImageDraw, ImageFont + +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, + source_file_path=None, + ): + self.file_path = file_path + self.start_time = start_time + self.end_time = end_time + self.width = width + self.height = height + self.source_file_path = source_file_path or file_path + 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 _prioritize_unique_source_clips( + subclipped_items: List[SubClippedVideoClip], + concat_mode: VideoConcatMode, +) -> List[SubClippedVideoClip]: + """ + 优先让每个源素材只出现一次,降低成片里同一素材反复出现的概率。 + + 线上素材经常会遇到“一个长视频被切成多个短片段”的情况。旧逻辑在 + random 模式下直接打乱所有短片段,导致同一个源视频的多个切片可能 + 分布在开头和中间,用户会感知为素材重复。本函数只调整片段顺序: + 先放每个源文件里最长的一个片段,剩余片段作为兜底;当素材总时长不足时, + 仍然允许后续片段补齐音频长度,避免破坏视频生成成功率。优先选择最长 + 片段是为了避免随机选中视频尾部的零碎短片段,导致明明有足够素材却过早复用。 + """ + if not subclipped_items: + return [] + + concat_mode_value = getattr(concat_mode, "value", concat_mode) + if concat_mode_value != VideoConcatMode.random.value: + return subclipped_items + + grouped_items: dict[str, list[SubClippedVideoClip]] = {} + for item in subclipped_items: + grouped_items.setdefault(item.source_file_path, []).append(item) + + primary_items = [] + overflow_items = [] + for items in grouped_items.values(): + primary_item = max(items, key=lambda item: item.duration) + primary_items.append(primary_item) + overflow_items.extend(item for item in items if item is not primary_item) + + random.shuffle(primary_items) + random.shuffle(overflow_items) + logger.info( + "prioritized unique video materials, " + f"sources: {len(grouped_items)}, " + f"primary clips: {len(primary_items)}, " + f"fallback clips: {len(overflow_items)}" + ) + return primary_items + overflow_items + + +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): + 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, + source_file_path=video_path, + ) + ) + + start_time = end_time + if video_concat_mode.value == VideoConcatMode.sequential.value: + break + + subclipped_items = _prioritize_unique_source_clips( + subclipped_items=subclipped_items, + concat_mode=video_concat_mode, + ) + + 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}, " + f"source: {os.path.basename(subclipped_item.source_file_path)}, " + f"current duration: {video_duration:.2f}s, " + f"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, + source_file_path=subclipped_item.source_file_path, + ) + ) + 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 _hex_to_rgb(color: str) -> tuple[int, int, int]: + # 字幕背景色来自 API/WebUI 参数,可能为空或格式不规范。这里统一只接受 + # #RRGGBB 形式,非法值回退为黑色,避免 PIL 渲染阶段抛出异常中断任务。 + if isinstance(color, str) and color.startswith("#") and len(color) == 7: + try: + return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)) + except ValueError: + pass + return (0, 0, 0) + + +def _rounded_subtitle_background_clip( + width: int, + height: int, + color: str, + alpha: int = 140, + radius: int = 16, +) -> ImageClip: + # 新字幕背景仅在用户显式开启时使用:通过 RGBA 图片绘制圆角半透明底板, + # 再交给 MoviePy 作为透明 ImageClip 参与合成。这样默认路径完全不变, + # 同时可以低成本试验更柔和的字幕视觉效果。 + rgb = _hex_to_rgb(color) + safe_alpha = max(0, min(255, int(alpha))) + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + draw.rounded_rectangle( + [0, 0, max(0, width - 1), max(0, height - 1)], + radius=max(0, int(radius)), + fill=(rgb[0], rgb[1], rgb[2], safe_alpha), + ) + return ImageClip(np.array(img), transparent=True) + + +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` 下会自动收缩文本框高度,遇到多行字幕、 + # 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入 + # 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕 + # 背景框与文字本身都能完整渲染出来。 + clip_h = int(txt_height + vertical_padding + (interline * line_count)) + bg_color = resolve_subtitle_background_color() + rounded_bg_enabled = bool( + getattr(params, "rounded_subtitle_background", False) and bg_color + ) + + if rounded_bg_enabled: + # 圆角背景需要贴合文字宽度,而不是沿用 90% 视频宽度。这里先用 + # PIL 测量最长一行文字,再加水平内边距,避免短字幕出现过宽底板。 + try: + font = ImageFont.truetype(font_path, params.font_size) + text_w = max( + int(font.getbbox(line)[2] - font.getbbox(line)[0]) + for line in wrapped_txt.split("\n") + ) + except Exception as exc: + logger.warning( + f"failed to measure subtitle text width, fallback to max width: {str(exc)}" + ) + text_w = int(max_width) + + pad_x = int(params.font_size * 0.6) + box_w = max(1, min(int(max_width), text_w + 2 * pad_x)) + radius = max(8, int(params.font_size * 0.4)) + text_clip = TextClip( + text=wrapped_txt, + font=font_path, + font_size=params.font_size, + color=params.text_fore_color, + bg_color=None, + stroke_color=params.stroke_color, + stroke_width=params.stroke_width, + interline=interline, + size=(box_w, clip_h), + text_align="center", + ) + bg_clip = _rounded_subtitle_background_clip( + width=box_w, + height=clip_h, + color=bg_color, + alpha=140, + radius=radius, + ) + _clip = CompositeVideoClip( + [bg_clip, text_clip.with_position("center")], + size=(box_w, clip_h), + ) + else: + size = ( + int(max_width), + clip_h, + ) + _clip = TextClip( + text=wrapped_txt, + font=font_path, + font_size=params.font_size, + color=params.text_fore_color, + bg_color=bg_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 0000000000000000000000000000000000000000..11d14fc6e3280e9668f3d7f9f98ce8048007d29b --- /dev/null +++ b/app/services/voice.py @@ -0,0 +1,1400 @@ +import asyncio +import base64 +import io +import inspect +import json +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 +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] + + +_AZURE_VOICES_DATA_FILE = os.path.join( + os.path.dirname(__file__), "data", "azure_voices.json" +) +_azure_voices_cache = None + + +def _load_azure_voices() -> list[dict]: + global _azure_voices_cache + if _azure_voices_cache is None: + with open(_AZURE_VOICES_DATA_FILE, "r", encoding="utf-8") as f: + _azure_voices_cache = json.load(f) + return _azure_voices_cache + + +def get_all_azure_voices(filter_locals=None) -> list[str]: + voices = [] + for item in _load_azure_voices(): + name = item["name"] + gender = item["gender"] + # 应用过滤条件 + 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 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: + """ + 清理字幕对齐前的脚本文本。 + + 这里不能只在 LLM 生成阶段处理,因为用户也可能手动粘贴脚本,或通过 + API 直接传入包含 Markdown 标记的文本。TTS 通常不会朗读 `---`、 + `___`、`***` 这类分隔符行,也不会朗读 `_` 这种强调标记;如果字幕 + 对齐仍保留这些字符,`create_subtitle()` 会一直等待不存在的 cue, + 最终导致字幕文件缺失并在 Whisper fallback 校正时补出全 0 时间轴。 + """ + text = text.replace("[", " ") + text = text.replace("]", " ") + text = text.replace("(", " ") + text = text.replace(")", " ") + text = text.replace("{", " ") + text = text.replace("}", " ") + return utils.normalize_script_for_subtitle_matching(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 + + +# 阿拉伯语变音符号和 Tatweel 拉长符在 edge-tts 返回文本中可能出现, +# 这些字符不影响语义,但会导致脚本文本和字幕 cue 字符串精确匹配失败。 +_ARABIC_DIACRITICS = re.compile("[\u0610-\u061A\u064B-\u065F\u0670\u0640\u06D6-\u06ED]") + + +def _normalize_arabic(text: str) -> str: + """统一阿拉伯语常见字母变体,提升字幕 cue 与脚本行的匹配容错率。 + + edge-tts 对阿拉伯语可能返回与原脚本不同的字母形态,例如把 أ/إ/آ + 归一成 ا,或者携带变音符号。这里仅在最后一层匹配兜底中使用, + 不改变原始字幕文本,避免影响最终展示内容。 + """ + text = _ARABIC_DIACRITICS.sub("", text) + for src, dst in ( + ("أإآٱ", "ا"), + ("ىئ", "ي"), + ("ة", "ه"), + ("ؤ", "و"), + ): + for ch in src: + text = text.replace(ch, dst) + return text + + +def _match_script_line(script_lines: list[str], current_text: str, sub_index: int) -> str: + """ + 尝试把当前累计的字幕文本,与脚本中的某一条标准断句匹配起来。 + + 这里复用了项目原有的“按标点拆脚本,再逐段比对”的思路: + 1. 优先精确匹配; + 2. 再做一次去标点和 Markdown `_` 格式符后的匹配; + 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]+", "", current_text) + target_line_normalized = re.sub(r"[_\W]+", "", target_line) + if current_text_normalized == target_line_normalized: + return target_line.strip() + + # 最后一层阿拉伯语容错:edge-tts 返回的字母形态、变音符号或 Tatweel + # 可能和脚本不同。只在常规匹配失败后归一化比较,非阿拉伯语文本不会受影响。 + current_ar = re.sub(r"[_\W]+", "", _normalize_arabic(current_text)) + target_ar = re.sub(r"[_\W]+", "", _normalize_arabic(target_line)) + if current_ar and current_ar == target_ar: + 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 0000000000000000000000000000000000000000..00366fcd7f857568d47dce61e7ec273abaf15b3d --- /dev/null +++ b/app/utils/file_security.py @@ -0,0 +1,35 @@ +import os + + +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 0000000000000000000000000000000000000000..69d46010e89531470aaad859952b858350f4bdc4 --- /dev/null +++ b/app/utils/utils.py @@ -0,0 +1,274 @@ +import json +import locale +import os +import re +from functools import lru_cache +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 normalize_script_for_subtitle_matching(video_script: str) -> str: + """ + 清理字幕匹配前的脚本文本。 + + 用户可能手动输入 Markdown 分隔符、标题强调或 `_` 这类格式符号。 + 这些字符通常不会出现在 TTS/Whisper 的识别结果里;如果继续参与 + 字幕逐行匹配,脚本行数量会大于真实字幕行数量,最终可能补出 + `00:00:00,000 --> 00:00:00,000`,导致剪辑软件无法导入 SRT。 + """ + video_script = video_script or "" + underscore_count = video_script.count("_") + video_script = video_script.replace("_", "") + cleaned_lines = [] + removed_separator_lines = 0 + for line in video_script.splitlines(): + line = line.strip() + # Markdown 分隔符或强调符号单独成行时不会被 TTS 朗读,必须从 + # 脚本行里移除,避免字幕聚合卡在这类“不可发声”的目标行上。 + if re.fullmatch(r"[-*_]{3,}", line): + removed_separator_lines += 1 + continue + cleaned_lines.append(line) + + normalized_script = "\n".join(cleaned_lines).strip() + if underscore_count or removed_separator_lines: + logger.debug( + "normalized script for subtitle matching, " + f"removed underscores: {underscore_count}, " + f"removed markdown separator lines: {removed_separator_lines}" + ) + return normalized_script + + +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" + + +@lru_cache(maxsize=None) +def load_locales(i18n_dir): + # WebUI 每次交互都会触发 Streamlit 重新执行脚本,语言文件运行期不会变化, + # 因此缓存解析结果,避免反复读取和解析所有 i18n JSON 文件。 + _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 0000000000000000000000000000000000000000..767ee2727f0418277823271c408ab570e98c3a54 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,325 @@ +[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 +# aihubmix (OpenAI-compatible AI model gateway) +# 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" + +########## AIHubMix API Key +# AIHubMix is OpenAI-compatible and can use one API key to access multiple models. +# Visit https://aihubmix.com/?aff=CEve to register and get your API key. +aihubmix_api_key = "" +aihubmix_base_url = "https://aihubmix.com/v1" +aihubmix_model_name = "gpt-5.4-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 0000000000000000000000000000000000000000..a15ea01a5f8b53ddf74ccb794228d6940b257a1f --- /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 0000000000000000000000000000000000000000..acad46263535796b11ed3f3423309f52fe3eaed0 --- /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/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..e84f32ba6c7d9d9db17e4f805abc6a43bbde28f4 --- /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 0000000000000000000000000000000000000000..727eecc80caac339328282e6506657db5eca9d84 --- /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 0000000000000000000000000000000000000000..0c8a66ace237ca2a94cd4dd532693386a14dc1e6 --- /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/resource/public/assets/index-CRiTZ9sN.css b/resource/public/assets/index-CRiTZ9sN.css new file mode 100644 index 0000000000000000000000000000000000000000..68873f0b7dc8d5109db91053930e6c01c9d90a4e --- /dev/null +++ b/resource/public/assets/index-CRiTZ9sN.css @@ -0,0 +1 @@ +@import "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap";:root{--bg-primary:#0f111a;--bg-secondary:#1a1d2d;--text-primary:#fff;--text-secondary:#94a3b8;--accent-primary:#6366f1;--accent-hover:#4f46e5;--success:#10b981;--warning:#f59e0b;--danger:#ef4444;--glass-bg:#1a1d2db3;--glass-border:#ffffff1a;--glass-shadow:0 8px 32px 0 #0000005e}*{box-sizing:border-box;margin:0;padding:0}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;background-image:radial-gradient(circle at 15%,#6366f126 0%,#0000 50%),radial-gradient(circle at 85% 30%,#10b9811a 0%,#0000 50%);background-attachment:fixed;min-height:100vh;font-family:Inter,sans-serif;line-height:1.5}h1,h2,h3,h4,h5,h6{letter-spacing:-.02em;font-family:Outfit,sans-serif;font-weight:600}.app-container{flex-direction:column;gap:2rem;max-width:1280px;margin:0 auto;padding:2rem;animation:.8s ease-out fadeIn;display:flex}.header{border-bottom:1px solid var(--glass-border);justify-content:space-between;align-items:center;padding-bottom:1rem;display:flex}.header h1{background:linear-gradient(135deg,#fff,#94a3b8);-webkit-text-fill-color:transparent;-webkit-background-clip:text;font-size:2.5rem}.header .status{color:var(--text-secondary);align-items:center;gap:.5rem;font-size:.875rem;display:flex}.status-indicator{background-color:var(--success);width:10px;height:10px;box-shadow:0 0 10px var(--success);border-radius:50%;animation:2s infinite pulse}.glass-panel{background:var(--glass-bg);-webkit-backdrop-filter:blur(12px);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow);border-radius:16px;padding:1.5rem;transition:transform .3s,box-shadow .3s}.glass-panel:hover{transform:translateY(-2px);box-shadow:0 12px 40px #00000073}.dashboard-grid{grid-template-columns:1fr 1fr;gap:2rem;display:grid}@media (width<=900px){.dashboard-grid{grid-template-columns:1fr}}.form-group{margin-bottom:1.25rem}.form-group label{color:var(--text-secondary);margin-bottom:.5rem;font-size:.875rem;font-weight:500;display:block}.form-control{color:#fff;background:#0003;border:1px solid #ffffff1a;border-radius:8px;width:100%;padding:.75rem 1rem;font-family:inherit;font-size:1rem;transition:all .2s}.form-control:focus{border-color:var(--accent-primary);background:#0006;outline:none;box-shadow:0 0 0 3px #6366f140}.btn{cursor:pointer;border:none;border-radius:8px;justify-content:center;align-items:center;gap:.5rem;padding:.75rem 1.5rem;font-family:inherit;font-weight:600;transition:all .2s;display:inline-flex}.btn-primary{background:var(--accent-primary);color:#fff;box-shadow:0 4px 14px #6366f163}.btn-primary:hover{background:var(--accent-hover);transform:translateY(-1px);box-shadow:0 6px 20px #6366f13b}.btn-primary:active{transform:translateY(1px)}.btn-primary:disabled{cursor:not-allowed;box-shadow:none;background:#475569;transform:none}.logs-console{background:#000;border:1px solid #ffffff0d;border-radius:8px;height:300px;padding:1rem;font-family:Consolas,Courier New,monospace;font-size:.875rem;overflow-y:auto}.log-entry{margin-bottom:.25rem;line-height:1.4}.log-time{color:#64748b;margin-right:.5rem}.log-info{color:#38bdf8}.log-success{color:#34d399}.log-error{color:#f87171}.log-warning{color:#fbbf24}@keyframes fadeIn{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%{box-shadow:0 0 #10b98166}70%{box-shadow:0 0 0 6px #10b98100}to{box-shadow:0 0 #10b98100}}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.spinner{border:2px solid #ffffff4d;border-top-color:#fff;border-radius:50%;width:1rem;height:1rem;animation:.8s linear infinite spin} diff --git a/resource/public/assets/index-DeUqfpS1.js b/resource/public/assets/index-DeUqfpS1.js new file mode 100644 index 0000000000000000000000000000000000000000..5f53493c5e691fc2a086773cc6be4a3b302dde15 --- /dev/null +++ b/resource/public/assets/index-DeUqfpS1.js @@ -0,0 +1,9 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function S(){}var C={H:null,A:null,T:null,S:null},te=Object.prototype.hasOwnProperty;function ne(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function re(e,t){return ne(e.type,t,e.props)}function ie(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ae(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var oe=/\/+/g;function se(e,t){return typeof e==`object`&&e&&e.key!=null?ae(``+e.key):t.toString(36)}function ce(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(S,S):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function le(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,le(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+se(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(oe,`$&/`)+`/`),le(o,r,i,``,function(e){return e})):o!=null&&(ie(o)&&(o=re(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(oe,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,ee||(ee=!0,ie());else{var t=n(l);t!==null&&se(x,t.startTime-e)}}var ee=!1,S=-1,C=5,te=-1;function ne(){return g?!0:!(e.unstable_now()-tet&&ne());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&se(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?ie():ee=!1}}}var ie;if(typeof y==`function`)ie=function(){y(re)};else if(typeof MessageChannel<`u`){var ae=new MessageChannel,oe=ae.port2;ae.port1.onmessage=re,ie=function(){oe.postMessage(null)}}else ie=function(){_(re,0)};function se(t,n){S=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(S),S=-1):h=!0,se(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ee||(ee=!0,ie()))),r},e.unstable_shouldYield=ne,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=f(),n=u(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1me||(e.current=pe[me],pe[me]=null,me--)}function D(e,t){me++,pe[me]=e.current,e.current=t}var ge=he(null),_e=he(null),ve=he(null),ye=he(null);function be(e,t){switch(D(ve,t),D(_e,e),D(ge,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}E(ge),D(ge,e)}function xe(){E(ge),E(_e),E(ve)}function Se(e){e.memoizedState!==null&&D(ye,e);var t=ge.current,n=Hd(t,e.type);t!==n&&(D(_e,e),D(ge,n))}function Ce(e){_e.current===e&&(E(ge),E(_e)),ye.current===e&&(E(ye),Qf._currentValue=fe)}var we,Te;function Ee(e){if(we===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);we=t&&t[1]||``,Te=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{De=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?Ee(n):``}function ke(e,t){switch(e.tag){case 26:case 27:case 5:return Ee(e.type);case 16:return Ee(`Lazy`);case 13:return e.child!==t&&t!==null?Ee(`Suspense Fallback`):Ee(`Suspense`);case 19:return Ee(`SuspenseList`);case 0:case 15:return Oe(e.type,!1);case 11:return Oe(e.type.render,!1);case 1:return Oe(e.type,!0);case 31:return Ee(`Activity`);default:return``}}function Ae(e){try{var t=``,n=null;do t+=ke(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var je=Object.prototype.hasOwnProperty,Me=t.unstable_scheduleCallback,Ne=t.unstable_cancelCallback,Pe=t.unstable_shouldYield,Fe=t.unstable_requestPaint,Ie=t.unstable_now,Le=t.unstable_getCurrentPriorityLevel,Re=t.unstable_ImmediatePriority,ze=t.unstable_UserBlockingPriority,Be=t.unstable_NormalPriority,Ve=t.unstable_LowPriority,He=t.unstable_IdlePriority,Ue=t.log,We=t.unstable_setDisableYieldValue,Ge=null,Ke=null;function qe(e){if(typeof Ue==`function`&&We(e),Ke&&typeof Ke.setStrictMode==`function`)try{Ke.setStrictMode(Ge,e)}catch{}}var Je=Math.clz32?Math.clz32:Ze,Ye=Math.log,Xe=Math.LN2;function Ze(e){return e>>>=0,e===0?32:31-(Ye(e)/Xe|0)|0}var Qe=256,$e=262144,et=4194304;function tt(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function nt(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=tt(n))):i=tt(o):i=tt(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=tt(n))):i=tt(o)):i=tt(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function rt(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function it(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function at(){var e=et;return et<<=1,!(et&62914560)&&(et=4194304),e}function ot(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function st(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function ct(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),bn=!1;if(yn)try{var xn={};Object.defineProperty(xn,"passive",{get:function(){bn=!0}}),window.addEventListener(`test`,xn,xn),window.removeEventListener(`test`,xn,xn)}catch{bn=!1}var Sn=null,Cn=null,wn=null;function Tn(){if(wn)return wn;var e,t=Cn,n=t.length,r,i=`value`in Sn?Sn.value:Sn.textContent,a=i.length;for(e=0;e=rr),or=` `,sr=!1;function cr(e,t){switch(e){case`keyup`:return tr.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function lr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var ur=!1;function dr(e,t){switch(e){case`compositionend`:return lr(t);case`keypress`:return t.which===32?(sr=!0,or):null;case`textInput`:return e=t.data,e===or&&sr?null:e;default:return null}}function fr(e,t){if(ur)return e===`compositionend`||!nr&&cr(e,t)?(e=Tn(),wn=Cn=Sn=null,ur=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Pr(n)}}function Ir(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ir(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Lr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Kt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Kt(e.document)}return t}function Rr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var zr=yn&&`documentMode`in document&&11>=document.documentMode,Br=null,Vr=null,Hr=null,Ur=!1;function Wr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ur||Br==null||Br!==Kt(r)||(r=Br,`selectionStart`in r&&Rr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Hr&&Nr(Hr,r)||(Hr=r,r=Ed(Vr,`onSelect`),0>=o,i-=o,Ii=1<<32-Je(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),A&&Ri(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),A&&Ri(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return A&&Ri(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),A&&Ri(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ie&&Fa(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ha(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=Ci(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=Si(o.type,o.key,o.props,null,e.mode,c),Ha(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=Ei(o,e.mode,c),c.return=e,e=c}return s(e);case ie:return o=Fa(o),b(e,r,o,c)}if(de(o))return h(e,r,o,c);if(ce(o)){if(l=ce(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Va(o),c);if(o.$$typeof===S)return b(e,r,ua(e,o),c);Ua(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=wi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Ba=0;var i=b(e,t,n,r);return za=null,i}catch(t){if(t===ka||t===ja)throw t;var a=vi(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ga=Wa(!0),Ka=Wa(!1),qa=!1;function Ja(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ya(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Xa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Za(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,W&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=hi(e),mi(e,null,n),t}return di(e,r,t,n),hi(e)}function Qa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ut(e,n)}}function $a(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var eo=!1;function to(){if(eo){var e=ba;if(e!==null)throw e}}function no(e,t,n,r){eo=!1;var i=e.updateQueue;qa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(q&f)===f:(r&f)===f){f!==0&&f===ya&&(eo=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:qa=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Kl|=o,e.lanes=o,e.memoizedState=d}}function ro(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function io(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=w.T,s={};w.T=s,zs(e,!1,t,n);try{var c=i(),l=w.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Rs(e,t,Ca(c,r),pu(e)):Rs(e,t,r,pu(e))}catch(n){Rs(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{T.p=a,o!==null&&s.types!==null&&(o.types=s.types),w.T=o}}function Os(){}function ks(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=As(e).queue;Ds(e,a,t,fe,n===null?Os:function(){return js(e),n(r)})}function As(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:fe,baseState:fe,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:fe},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function js(e){var t=As(e);t.next===null&&(t=e.alternate.memoizedState),Rs(e,t.next.queue,{},pu())}function Ms(){return j(Qf)}function Ns(){return R().memoizedState}function Ps(){return R().memoizedState}function Fs(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Xa(n);var r=Za(t,e,n);r!==null&&(hu(r,t,n),Qa(r,t,n)),t={cache:ha()},e.payload=t;return}t=t.return}}function Is(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Bs(e)?Vs(t,n):(n=fi(e,t,n,r),n!==null&&(hu(n,e,r),Hs(n,t,r)))}function Ls(e,t,n){Rs(e,t,n,pu())}function Rs(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Bs(e))Vs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Mr(s,o))return di(e,t,i,0),G===null&&ui(),!1}catch{}if(n=fi(e,t,i,r),n!==null)return hu(n,e,r),Hs(n,t,r),!0}return!1}function zs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Bs(e)){if(t)throw Error(i(479))}else t=fi(e,n,r,2),t!==null&&hu(t,e,2)}function Bs(e){var t=e.alternate;return e===P||t!==null&&t===P}function Vs(e,t){xo=bo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Hs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ut(e,n)}}var Us={readContext:j,use:Ro,useCallback:L,useContext:L,useEffect:L,useImperativeHandle:L,useLayoutEffect:L,useInsertionEffect:L,useMemo:L,useReducer:L,useRef:L,useState:L,useDebugValue:L,useDeferredValue:L,useTransition:L,useSyncExternalStore:L,useId:L,useHostTransitionStatus:L,useFormState:L,useActionState:L,useOptimistic:L,useMemoCache:L,useCacheRefresh:L};Us.useEffectEvent=L;var Ws={readContext:j,use:Ro,useCallback:function(e,t){return Fo().memoizedState=[e,t===void 0?null:t],e},useContext:j,useEffect:ms,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),fs(4194308,4,bs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return fs(4194308,4,e,t)},useInsertionEffect:function(e,t){fs(4,2,e,t)},useMemo:function(e,t){var n=Fo();t=t===void 0?null:t;var r=e();if(So){qe(!0);try{e()}finally{qe(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Fo();if(n!==void 0){var i=n(t);if(So){qe(!0);try{n(t)}finally{qe(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Is.bind(null,P,e),[r.memoizedState,e]},useRef:function(e){var t=Fo();return e={current:e},t.memoizedState=e},useState:function(e){e=Xo(e);var t=e.queue,n=Ls.bind(null,P,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Ss,useDeferredValue:function(e,t){return Ts(Fo(),e,t)},useTransition:function(){var e=Xo(!1);return e=Ds.bind(null,P,e.queue,!0,!1),Fo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=P,a=Fo();if(A){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),G===null)throw Error(i(349));q&127||Go(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ms(qo.bind(null,r,o,e),[e]),r.flags|=2048,us(9,{destroy:void 0},Ko.bind(null,r,o,n,t),null),n},useId:function(){var e=Fo(),t=G.identifierPrefix;if(A){var n=Li,r=Ii;n=(r&~(1<<32-Je(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=Co++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[_t]=t,o[vt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Lc(t)}}return B(t),Rc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Lc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ve.current,Xi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Ui,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[_t]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||qi(t,!0)}else e=Bd(e).createTextNode(r),e[_t]=t,t.stateNode=e}return B(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Xi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[_t]=t}else Zi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;B(t),e=!1}else n=Qi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(_o(t),t):(_o(t),null);if(t.flags&128)throw Error(i(558))}return B(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Xi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[_t]=t}else Zi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;B(t),a=!1}else a=Qi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(_o(t),t):(_o(t),null)}return _o(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Bc(t,t.updateQueue),B(t),null);case 4:return xe(),e===null&&Sd(t.stateNode.containerInfo),B(t),null;case 10:return ia(t.type),B(t),null;case 19:if(E(N),r=t.memoizedState,r===null)return B(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Vc(r,!1);else{if(Y!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=vo(e),o!==null){for(t.flags|=128,Vc(r,!1),e=o.updateQueue,t.updateQueue=e,Bc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)xi(n,e),n=n.sibling;return D(N,N.current&1|2),A&&Ri(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ie()>nu&&(t.flags|=128,a=!0,Vc(r,!1),t.lanes=4194304)}else{if(!a)if(e=vo(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Bc(t,e),Vc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!A)return B(t),null}else 2*Ie()-r.renderingStartTime>nu&&n!==536870912&&(t.flags|=128,a=!0,Vc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(B(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ie(),e.sibling=null,n=N.current,D(N,a?n&1|2:n&1),A&&Ri(t,r.treeForkCount),e);case 22:case 23:return _o(t),lo(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(B(t),t.subtreeFlags&6&&(t.flags|=8192)):B(t),n=t.updateQueue,n!==null&&Bc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&E(Ta),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),ia(M),B(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Uc(e,t){switch(Vi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ia(M),xe(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Ce(t),null;case 31:if(t.memoizedState!==null){if(_o(t),t.alternate===null)throw Error(i(340));Zi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(_o(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Zi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return E(N),null;case 4:return xe(),null;case 10:return ia(t.type),null;case 22:case 23:return _o(t),lo(),e!==null&&E(Ta),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return ia(M),null;case 25:return null;default:return null}}function Wc(e,t){switch(Vi(t),t.tag){case 3:ia(M),xe();break;case 26:case 27:case 5:Ce(t);break;case 4:xe();break;case 31:t.memoizedState!==null&&_o(t);break;case 13:_o(t);break;case 19:E(N);break;case 10:ia(t.type);break;case 22:case 23:_o(t),lo(),e!==null&&E(Ta);break;case 24:ia(M)}}function Gc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Kc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function qc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{io(t,n)}catch(t){Z(e,e.return,t)}}}function Jc(e,t,n){n.props=Zs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Yc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function Xc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}function Zc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Qc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[vt]=t}catch(t){Z(e,e.return,t)}}function $c(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function el(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||$c(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function tl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=un));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(tl(e,t,n),e=e.sibling;e!==null;)tl(e,t,n),e=e.sibling}function nl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(nl(e,t,n),e=e.sibling;e!==null;)nl(e,t,n),e=e.sibling}function rl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[_t]=e,t[vt]=n}catch(t){Z(e,e.return,t)}}var il=!1,V=!1,al=!1,ol=typeof WeakSet==`function`?WeakSet:Set,H=null;function sl(e,t){if(e=e.containerInfo,Rd=sp,e=Lr(e),Rr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,H=t;H!==null;)if(t=H,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,H=e;else for(;H!==null;){switch(t=H,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[_t]=e,O(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Fr(s,h),v=Fr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,w.T=null,n=lu,lu=null;var o=au,s=su;if(X=0,ou=au=null,su=0,W&6)throw Error(i(331));var c=W;if(W|=4,Il(o.current),Ol(o,o.current,s,n),W=c,id(0,!1),Ke&&typeof Ke.onPostCommitFiberRoot==`function`)try{Ke.onPostCommitFiberRoot(Ge,o)}catch{}return!0}finally{T.p=a,w.T=r,Vu(e,t)}}function Wu(e,t,n){t=Oi(n,t),t=rc(e.stateNode,t,2),e=Za(e,t,2),e!==null&&(st(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(iu===null||!iu.has(r))){e=Oi(n,e),n=ic(2),r=Za(t,n,2),r!==null&&(ac(n,r,t,e),st(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Bl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Wl=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,G===e&&(q&n)===n&&(Y===4||Y===3&&(q&62914560)===q&&300>Ie()-eu?!(W&2)&&Su(e,0):Jl|=n,Xl===q&&(Xl=0)),rd(e)}function qu(e,t){t===0&&(t=at()),e=pi(e,t),e!==null&&(st(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Me(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Je(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=q,a=nt(r,r===G?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||rt(r,a)||(n=!0,ld(r,a));r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=Ie(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}X!==0&&X!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Jt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),O(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Jt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Jt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Jt(n.imageSizes)+`"]`)):i+=`[href="`+Jt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),O(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Jt(r)+`"][href="`+Jt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),O(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=kt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);O(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=kt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),O(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=kt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),O(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=ve.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=kt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=kt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=kt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+Jt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),O(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Jt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Jt(n.href)+`"]`);if(r)return t.instance=r,O(r),r;var a=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),O(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,O(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),O(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,O(a),a):(r=n,(a=mf.get(o))&&(r=h({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),O(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,O(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),O(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=c(u(),1),v=g(),y=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),b=o(((e,t)=>{t.exports=y()}))(),x=({logs:e})=>{let t=(0,_.useRef)(null);return(0,_.useEffect)(()=>{t.current&&(t.current.scrollTop=t.current.scrollHeight)},[e]),(0,b.jsxs)(`div`,{className:`glass-panel`,style:{display:`flex`,flexDirection:`column`,height:`100%`},children:[(0,b.jsx)(`h3`,{style:{marginBottom:`1rem`},children:`Task Execution Logs`}),(0,b.jsx)(`div`,{className:`logs-console`,ref:t,style:{flex:1,minHeight:`400px`},children:e.length===0?(0,b.jsx)(`div`,{style:{color:`#475569`,fontStyle:`italic`,padding:`1rem`},children:`System idle. Waiting for tasks...`}):e.map((e,t)=>{let n=`log-info`,r=typeof e==`string`?e:e.text,i=typeof e==`string`?new Date().toLocaleTimeString():e.time;return r.toLowerCase().includes(`error`)||r.toLowerCase().includes(`fail`)?n=`log-error`:r.toLowerCase().includes(`success`)||r.toLowerCase().includes(`done`)?n=`log-success`:r.toLowerCase().includes(`warn`)&&(n=`log-warning`),(0,b.jsxs)(`div`,{className:`log-entry ${n}`,children:[(0,b.jsxs)(`span`,{className:`log-time`,children:[`[`,i,`]`]}),r]},t)})})]})},ee=()=>window.location.port===`5173`?`http://127.0.0.1:8080`:window.location.origin,S=()=>{let[e,t]=(0,_.useState)(`History of the Roman Empire in 60 seconds`),[n,r]=(0,_.useState)(`nvidia`),[i,a]=(0,_.useState)(!1),[o,s]=(0,_.useState)([]),[c,l]=(0,_.useState)(0),[u,d]=(0,_.useState)(null),[f,p]=(0,_.useState)(``),m=(0,_.useRef)(-1),h=e=>{s(t=>[...t,{time:new Date().toLocaleTimeString(),text:e}])};return(0,b.jsxs)(`div`,{className:`dashboard-grid`,children:[(0,b.jsxs)(`div`,{className:`glass-panel`,children:[(0,b.jsx)(`h2`,{style:{marginBottom:`1.5rem`},children:`Task Configuration`}),(0,b.jsxs)(`form`,{onSubmit:async t=>{t.preventDefault(),a(!0),s([]),l(0),m.current=-1,h(`Starting video generation task...`),h(`Topic: ${e}`),h(`Connecting to MoneyPrinterTurbo backend API (${ee()})`);try{h(`[INFO] Sending payload to /api/v1/videos...`);let t={video_subject:e,video_aspect:`9:16`,voice_name:`en-US-JennyNeural`,video_format:`mp4`,video_language:`en-US`,video_source:n},r=await fetch(`${ee()}/api/v1/videos`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)});if(!r.ok){h(`[ERROR] Backend connection failed or error returned. Status: ${r.status}`),h(`Is the MoneyPrinterTurbo FastAPI backend running on ${ee()}?`),a(!1);return}let i=await r.json();h(`[SUCCESS] Task accepted. Task ID: ${i.data.task_id}`);let o=i.data.task_id;d(null),p(o),l(5),m.current=5;let s=setInterval(async()=>{try{let e=await fetch(`${ee()}/api/v1/tasks/${o}`);if(e.ok){let t=(await e.json()).data||{},n=t.progress||0;if(l(n),n!==m.current&&n>0&&(h(`[INFO] Processing: ${n}%`),m.current=n),t.state===1){h(`[SUCCESS] Video Generation Complete!`);let e=t.videos&&t.videos[0];if(e){let t=e.startsWith(`http`)?e:`${ee()}${e}`;h(`[SUCCESS] Video URL: ${t}`),d(t)}else h(`[SUCCESS] Task completed but no final video URL was returned.`);a(!1),clearInterval(s)}else t.state===-1&&(h(`[ERROR] Task failed: ${t.error_message||`Unknown error`}`),a(!1),clearInterval(s))}}catch(e){h(`[ERROR] Error polling status: ${e.message}`)}},5e3)}catch(e){h(`[ERROR] Failed to fetch: ${e.message}`),h(`Please ensure the MoneyPrinterTurbo backend is running on ${ee()}.`),a(!1)}},children:[(0,b.jsxs)(`div`,{className:`form-group`,children:[(0,b.jsx)(`label`,{htmlFor:`topic`,children:`Video Topic / Script`}),(0,b.jsx)(`input`,{type:`text`,id:`topic`,className:`form-control`,value:e,onChange:e=>t(e.target.value),disabled:i,required:!0,placeholder:`e.g. History of the Roman Empire in 60 seconds`})]}),(0,b.jsxs)(`div`,{className:`form-group`,children:[(0,b.jsx)(`label`,{htmlFor:`language`,children:`Language`}),(0,b.jsxs)(`select`,{id:`language`,className:`form-control`,disabled:i,children:[(0,b.jsx)(`option`,{value:`en-US`,children:`English (en-US)`}),(0,b.jsx)(`option`,{value:`zh-CN`,children:`Chinese (zh-CN)`})]})]}),(0,b.jsxs)(`div`,{className:`form-group`,children:[(0,b.jsx)(`label`,{htmlFor:`source`,children:`Video Source`}),(0,b.jsxs)(`select`,{id:`source`,className:`form-control`,value:n,onChange:e=>r(e.target.value),disabled:i,children:[(0,b.jsx)(`option`,{value:`nvidia`,children:`Nvidia Image Generation API`}),(0,b.jsx)(`option`,{value:`pexels`,children:`Pexels (Stock Video)`}),(0,b.jsx)(`option`,{value:`pixabay`,children:`Pixabay (Stock Video)`})]})]}),(0,b.jsxs)(`div`,{className:`form-group`,children:[(0,b.jsx)(`label`,{htmlFor:`aspect`,children:`Video Format`}),(0,b.jsxs)(`select`,{id:`aspect`,className:`form-control`,disabled:i,children:[(0,b.jsx)(`option`,{value:`9:16`,children:`Shorts / TikTok (9:16)`}),(0,b.jsx)(`option`,{value:`16:9`,children:`YouTube Standard (16:9)`})]})]}),(0,b.jsxs)(`div`,{className:`form-group`,children:[(0,b.jsx)(`label`,{htmlFor:`voice`,children:`Voice Model (Edge TTS)`}),(0,b.jsxs)(`select`,{id:`voice`,className:`form-control`,disabled:i,children:[(0,b.jsx)(`option`,{value:`en-US-JennyNeural`,children:`Jenny (Female, US)`}),(0,b.jsx)(`option`,{value:`en-US-GuyNeural`,children:`Guy (Male, US)`}),(0,b.jsx)(`option`,{value:`en-GB-SoniaNeural`,children:`Sonia (Female, UK)`})]})]}),(0,b.jsx)(`button`,{type:`submit`,className:`btn btn-primary`,style:{width:`100%`,marginTop:`1rem`},disabled:i,children:i?(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(`div`,{className:`spinner`}),`Generating Video...`]}):`Start Generation`})]}),i&&(0,b.jsxs)(`div`,{style:{marginTop:`1.5rem`,animation:`fadeIn 0.5s ease`},children:[(0,b.jsxs)(`div`,{style:{display:`flex`,justifyContent:`space-between`,marginBottom:`0.5rem`,fontSize:`0.875rem`},children:[(0,b.jsx)(`span`,{style:{color:`var(--text-secondary)`},children:`Status: Processing assets...`}),(0,b.jsxs)(`span`,{style:{fontWeight:`bold`,color:`var(--accent-primary)`},children:[c,`%`]})]}),(0,b.jsx)(`div`,{style:{width:`100%`,height:`8px`,background:`rgba(0,0,0,0.3)`,borderRadius:`4px`,overflow:`hidden`,border:`1px solid var(--glass-border)`},children:(0,b.jsx)(`div`,{style:{width:`${c}%`,height:`100%`,background:`linear-gradient(90deg, var(--accent-primary) 0%, var(--success) 100%)`,borderRadius:`4px`,transition:`width 0.5s cubic-bezier(0.4, 0, 0.2, 1)`}})})]})]}),(0,b.jsx)(x,{logs:o}),u&&(0,b.jsxs)(`div`,{className:`glass-panel`,style:{gridColumn:`span 2`,marginTop:`1rem`,display:`flex`,flexDirection:`column`,gap:`1.5rem`},children:[(0,b.jsxs)(`h3`,{style:{borderBottom:`1px solid var(--glass-border)`,paddingBottom:`0.75rem`,display:`flex`,alignItems:`center`,gap:`0.5rem`,color:`var(--success)`},children:[(0,b.jsx)(`span`,{children:`✓`}),` Generated Video Preview`]}),(0,b.jsxs)(`div`,{style:{display:`flex`,gap:`2rem`,flexWrap:`wrap`},children:[(0,b.jsx)(`div`,{style:{flex:`1`,minWidth:`280px`,display:`flex`,justifyContent:`center`,background:`rgba(0,0,0,0.2)`,padding:`1rem`,borderRadius:`12px`,border:`1px solid var(--glass-border)`},children:(0,b.jsx)(`video`,{src:u,controls:!0,style:{width:`100%`,maxWidth:`360px`,maxHeight:`480px`,borderRadius:`8px`,boxShadow:`0 8px 32px rgba(0,0,0,0.5)`}})}),(0,b.jsxs)(`div`,{style:{flex:`1.5`,minWidth:`300px`,display:`flex`,flexDirection:`column`,justifyContent:`space-between`,padding:`0.5rem 0`},children:[(0,b.jsxs)(`div`,{children:[(0,b.jsx)(`h4`,{style:{color:`var(--success)`,marginBottom:`0.75rem`,fontSize:`1.25rem`},children:`✨ Generation Successful!`}),(0,b.jsx)(`p`,{style:{color:`var(--text-secondary)`,fontSize:`0.95rem`,marginBottom:`1.5rem`,lineHeight:`1.6`},children:`Your automated video has been rendered perfectly! Below are the download links and the exact directory where all generated components (audio, subtitle, frames, raw clip chunks) are saved.`}),(0,b.jsxs)(`div`,{className:`form-group`,style:{marginBottom:`1.5rem`},children:[(0,b.jsx)(`label`,{style:{fontSize:`0.75rem`,textTransform:`uppercase`,letterSpacing:`0.05em`,color:`var(--text-secondary)`},children:`Filesystem Storage Location`}),(0,b.jsx)(`div`,{style:{background:`rgba(0, 0, 0, 0.3)`,padding:`0.75rem 1rem`,borderRadius:`8px`,fontSize:`0.8rem`,fontFamily:`monospace`,wordBreak:`break-all`,border:`1px solid var(--glass-border)`,color:`#38bdf8`},children:window.location.hostname===`localhost`||window.location.hostname===`127.0.0.1`?`MoneyPrinterTurbo\\storage\\tasks\\${f}`:`/app/storage/tasks/${f}`})]})]}),(0,b.jsxs)(`div`,{style:{display:`flex`,gap:`1rem`,flexWrap:`wrap`},children:[(0,b.jsx)(`a`,{href:u,download:`video_${f}.mp4`,className:`btn btn-primary`,style:{flex:`1`,minWidth:`150px`,textDecoration:`none`,textAlign:`center`},children:`Download Rendered Video`}),(0,b.jsx)(`a`,{href:`${ee()}/api/v1/download/${f}/final-1.mp4`,className:`btn`,style:{flex:`1`,minWidth:`150px`,border:`1px solid var(--glass-border)`,background:`rgba(255,255,255,0.05)`,color:`white`,textDecoration:`none`,textAlign:`center`},children:`Download Raw Video`})]})]})]})]})]})};function C(){return(0,b.jsxs)(`div`,{className:`app-container`,children:[(0,b.jsxs)(`header`,{className:`header`,children:[(0,b.jsxs)(`div`,{children:[(0,b.jsx)(`h1`,{children:`MoneyPrinterTurbo`}),(0,b.jsx)(`p`,{style:{color:`var(--text-secondary)`},children:`Automated Video Generation Studio`})]}),(0,b.jsxs)(`div`,{className:`status`,children:[(0,b.jsx)(`div`,{className:`status-indicator`}),(0,b.jsx)(`span`,{children:`System Online`})]})]}),(0,b.jsx)(`main`,{children:(0,b.jsx)(S,{})})]})}(0,v.createRoot)(document.getElementById(`root`)).render((0,b.jsx)(_.StrictMode,{children:(0,b.jsx)(C,{})})); \ No newline at end of file diff --git a/resource/public/favicon.svg b/resource/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..6893eb13237060adc0c968a690149a49faa2d7d3 --- /dev/null +++ b/resource/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resource/public/icons.svg b/resource/public/icons.svg new file mode 100644 index 0000000000000000000000000000000000000000..e9522193d9f796a9748e9ad8c952a5df73c87db9 --- /dev/null +++ b/resource/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resource/public/index.html b/resource/public/index.html new file mode 100644 index 0000000000000000000000000000000000000000..674ccea8c821019fbf9bcfb2a30f4284289d78a2 --- /dev/null +++ b/resource/public/index.html @@ -0,0 +1,14 @@ + + + + + + + dashboard-ui + + + + +
+ + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..b81562bf06d2e3c7b5c43ed8e8757f4dcefce0f6 --- /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 0000000000000000000000000000000000000000..cc20c7689c135941ec0eff379a8711bbf5a076ba --- /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 0000000000000000000000000000000000000000..fce0505446da750633186ec3eebaf26b4a9d8461 --- /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 0000000000000000000000000000000000000000..e500d63aedae727ee0606e464188cc6b4724a177 --- /dev/null +++ b/webui/Main.py @@ -0,0 +1,1298 @@ +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")) + # 下拉框需要展示“AIHubMix(推荐)”这类面向用户的文案, + # 但配置文件和后端逻辑必须继续使用稳定的小写 provider id。 + # 因此这里显式维护 display label 和 provider id 的映射,避免 + # UI 文案变化污染 `config.app["llm_provider"]`。 + llm_provider_options = [ + ("OpenAI", "openai"), + ("AIHubMix(推荐)", "aihubmix"), + ("Moonshot", "moonshot"), + ("Azure", "azure"), + ("Qwen", "qwen"), + ("DeepSeek", "deepseek"), + ("ModelScope", "modelscope"), + ("Gemini", "gemini"), + ("Grok", "grok"), + ("Ollama", "ollama"), + ("G4f", "g4f"), + ("OneAPI", "oneapi"), + ("Cloudflare", "cloudflare"), + ("ERNIE", "ernie"), + ("MiMo", "mimo"), + ("Pollinations", "pollinations"), + ("LiteLLM", "litellm"), + ] + llm_provider_labels = [label for label, _ in llm_provider_options] + llm_provider_values = { + label: provider_id for label, provider_id in llm_provider_options + } + saved_llm_provider = config.app.get("llm_provider", "openai").lower() + saved_llm_provider_index = 0 + for i, (_, provider_id) in enumerate(llm_provider_options): + if provider_id == saved_llm_provider: + saved_llm_provider_index = i + break + + llm_provider_label = st.selectbox( + tr("LLM Provider"), + options=llm_provider_labels, + index=saved_llm_provider_index, + ) + llm_helper = st.container() + llm_provider = llm_provider_values[llm_provider_label] + 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 == "aihubmix": + if not llm_model_name: + llm_model_name = "gpt-5.4-mini" + if not llm_base_url: + llm_base_url = "https://aihubmix.com/v1" + with llm_helper: + tips = """ + ##### AIHubMix 配置说明 + - **注册链接**: [点击注册 AIHubMix](https://aihubmix.com/?aff=CEve) + - **Base Url**: 预填 https://aihubmix.com/v1 + - **推荐模型**: 默认 gpt-5.4-mini,也可以填写 AIHubMix 支持的免费模型或其它模型 ID + + 推荐理由: + - **模型全**: Claude、GPT、Gemini、Grok、DeepSeek、通义等 700+ 模型一站覆盖 + - **稳定**: 无限并发,永远在线,集群部署于谷歌云,长期为众多知名应用提供高并发服务 + - **能力完整**: 文本、图片生成、视频生成、TTS、STT、向量嵌入、Rerank,多模态场景全搞定 + - **计费透明**: 按量付费,无会员无包月,免费模型可使用 + """ + + 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": + # AIHubMix 自身就是 OpenAI-compatible 聚合平台;用户主动选择 + # 该 provider 时,再显示 DeepSeek/Moonshot 的通用推荐会造成 + # 信息干扰,也不利于保持合作入口的轻量、清晰。 + if llm_provider != "aihubmix": + 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) + saved_rounded_subtitle_background = config.ui.get( + "rounded_subtitle_background", False + ) + params.rounded_subtitle_background = st.checkbox( + tr("Rounded Subtitle Background"), + value=saved_rounded_subtitle_background, + help=tr("Rounded Subtitle Background Help"), + ) + config.ui["rounded_subtitle_background"] = params.rounded_subtitle_background + 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 0000000000000000000000000000000000000000..75c536577d8e35a04cea96b22a21f6e1854b0b42 --- /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 0000000000000000000000000000000000000000..4a201e2a8ab0e64701091105fe041f9dbcf854c3 --- /dev/null +++ b/webui/i18n/en.json @@ -0,0 +1,137 @@ +{ + "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", + "Rounded Subtitle Background": "Enable rounded translucent subtitle background", + "Rounded Subtitle Background Help": "Disabled by default. When enabled, subtitle backgrounds use a rounded translucent style that better fits the text 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 0000000000000000000000000000000000000000..90c47c627b00d234e5d8dfbcc9ef22f6996dd0c1 --- /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 0000000000000000000000000000000000000000..77b34ae1362790acc85f3860a863c228a3920ad3 --- /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 0000000000000000000000000000000000000000..1de41d42c0efd369f6abb985fd5afa7733fc0cdc --- /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 0000000000000000000000000000000000000000..2ecbaa86fee72e41ab6bae874921395aef1e6fdb --- /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 0000000000000000000000000000000000000000..15676aaba483e3e142dabaa065f0abd85cd8ec43 --- /dev/null +++ b/webui/i18n/zh.json @@ -0,0 +1,137 @@ +{ + "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": "描边粗细", + "Rounded Subtitle Background": "启用圆角半透明字幕背景", + "Rounded Subtitle Background Help": "默认关闭。开启后字幕背景会显示为更贴合文字宽度的圆角半透明样式。", + "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 删除成功" + } +}