commit 4746e0363f5ba14210eae3b07829491250cb7c15 Author: catShark <1716967236@qq.com> Date: Sun Aug 16 23:58:25 2026 +0800 feat: VRSub 单体应用(WOV 单机版)初始提交 为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点 (提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。 - 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁 - 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据 - 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续 - 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用 自适应线程池弹性并发,并打印数据处理速度进度日志 - 100% 行覆盖率(pytest --cov-fail-under=100) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4b673f --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +.coverage +htmlcov/ +.venv/ +*.egg-info/ +build/ +dist/ + +# 本地环境变量(含 API 密钥,禁止入库) +.env + +# 本地数据与产物(上传文件、SQLite、节点中间产物) +data/ + +# 模型权重(大文件,不入库;如需共享请走对象存储或模型仓库) +model/ +# 编辑器与系统 +.DS_Store +Thumbs.db +.idea/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..94d99d8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,339 @@ +# VRSub(单体版) + +本仓库是"为视频生成 VR 双眼字幕"的单体应用(WOV AI Workflow Platform 的 +单机实现):FastAPI 后端、工作流调度器与全部节点(提音 / 转写 / 翻译 / +ASS)在**同一个进程**内运行,不再启动子进程、不再走节点 HTTP 协议。 +由原分布式多仓库(wov-api / wov-web / wov-sdk / wov-node-*)合并而来, +本 AGENTS.md 汇总了各仓库的约定与规范。 + +## 架构概览 + +``` +vrsub/ +├── src/wov_sdk/ # 协议数据模型(NodeManifest/InvokeRequest/InvokeResponse/ +│ # WorkflowDefinition 等),与分布式版保持一致 +├── src/wov_app/ # 应用层:main/config/db/registry/scheduler/seed/routers +│ └── routers/ # apps.py(用户端)、workflows.py(管理端) +├── nodes/ # 进程内节点实现:echo/ffmpeg/whisper/llm/ass +├── manifests/ # 各节点清单 JSON(echo.json/ffmpeg.json/...) +├── workflows/ # 默认工作流定义 JSON(模型/链路均为数据,改模型不改代码) +├── web/ # 静态前端(index/tasks/admin/workflow + assets) +├── model/ # 本地 whisper 权重(gitignored) +├── data/ # SQLite + 上传/产物存储(gitignored) +└── tests/ # 全部单元/API/冒烟测试(100% 覆盖率) +``` + +### 核心机制 + +- **节点注册表**(`src/wov_app/registry.py`):启动时把 `manifests/*.json` 与 + `nodes/*.py` 的 `invoke` 处理器静态注册到进程内字典,调度器按 + `node_type` 直接调用。协议数据模型不变,为将来回退分布式保留兼容桥梁。 +- **调度器**(`src/wov_app/scheduler.py`):后台线程轮询 SQLite 中的 QUEUED + 任务,按工作流 DAG 拓扑顺序调用节点,产物按 + `data/storage/runs//steps//` 落盘并登记到 artifacts 表。 +- **前端**:由 FastAPI 静态挂载 `web/`,节点注册/实例管理页面已移除, + 仅保留应用中心、任务管理、管理后台(工作流)与工作流编排。 + +## 节点输入/输出协议 + +节点统一签名 `invoke(request: InvokeRequest) -> InvokeResponse`,通过产物 URI +交换数据(节点之间不直接调用,不共享内存状态)。 + +| 节点 ID(node_type) | 输入 | 输出 | 说明 | +| --- | --- | --- | --- | +| `echo` | `text` / `file_uri` | `text`、`file_uri` | 示例节点,验证协议链路 | +| `ffmpeg-extract` | `video_uri` | `audio_uri`(WAV) | 参数:`sample_rate`、`channels` | +| `faster-whisper` | `audio_uri`(16kHz 单声道) | `srt_uri` | 参数:`language`、`task`、`model_path`、`device`、`compute_type`、`beam_size`、`vad_filter`(默认开)、`condition_on_previous_text`、`chunk_seconds` | +| `llm-translate` | `srt_uri` | `cn_srt_uri` | 参数:`target_language`、`model` | +| `vlm-ocr` | `image_uri` | `text`、`text_uri` | 直接调本地 Ollama 多模态模型(glm-ocr)的 `/api/chat` 做视频帧 OCR(流式 + 5s 上限),参数:`model`、`ollama_host`、`prompt`、`timeout_seconds`、`keep_alive`、`num_predict`、`temperature`、`repeat_penalty` | +| `frame-extract` | `video_uri` | `frames_manifest`、`frame_count` | 按**帧间隔**抽帧(解析 fps → step=round(间隔秒×fps),ffmpeg select 按帧号精确取帧,帧时间=帧号/fps 无累计偏差)并 crop 裁切字幕区域,参数:`interval_seconds`(默认 0.5)、`crop`([x,y,w,h] 0~1) | +| `subtitle-ocr` | `frames_manifest` | `srt_uri`、`count` | 自适应线程池并发逐帧调 vlm-ocr → 垃圾过滤(无文字帧)→ 相同字幕合并(记录最后可见帧)→ 组装 SRT,消失时间=最后可见帧+采样间隔(间隔从帧清单推导),参数:`min_chars`、`min_alnum_ratio`、`garbage_tokens`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` | +| `llm-filter` | `srt_uri` | `srt_uri`、`kept`、`removed` | LLM 过滤无意义字幕(自适应线程池并发判断):每条连同前后各 `context_size`(默认 10)条纯文本(不含时间戳)分批给 LLM,仅判断目标字幕是否多余/无意义,判定删除则该条连同时间戳移除并重新编号,参数:`context_size`、`model`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` | +| `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`,如 `3840x1920` | + +### 模型权重解析(本地优先) + +whisper 节点按以下顺序解析模型路径,默认避免从远端下载: + +1. 请求参数 `model_path`;裸模型名(不含路径分隔符)会在 `model/<名称>` 下解析。 +2. 环境变量 `WHISPER_MODEL_PATH`。 +3. 本地候选目录(存在且含 `model.bin` 即使用): + - 单体根目录 `model/faster-whisper-large-v3`。 + - `nodes/model/faster-whisper-large-v3`。 +4. 兜底:`large-v3`(需要联网从 Hugging Face 下载)。 + +把权重放在 `model/` 目录即可完全离线运行。当前已下载模型: + +- `model/faster-whisper-large-v3`:通用转写模型(demo 工作流)。 +- `model/whisper-large-v2-translate-zh-v0.2-st-ct2`:中文直出模型 + (`chickenrice0721/whisper-large-v2-translate-zh-v0.2-st-ct2`),配合 + `task=translate` 直接生成中文,无需 LLM 翻译(zh-direct 工作流)。 +- `model/whisper-large-v3-translate-zh-v0.1-lt-ct2`:早期中文直出模型, + 已无工作流引用,保留在盘上待处理。 + +### 内置工作流 + +| ID | 名称 | 链路 | 说明 | +| --- | --- | --- | --- | +| `demo` | 视频字幕生成 | 提音 → 转写 → LLM 翻译 → ASS | 通用链路,翻译走 SiliconFlow | +| `zh-direct` | 中文直出字幕 | 提音 → 中文转写 → ASS | 中文直出模型,无 LLM 步骤 | +| `ocr-subtitle` | 字幕OCR提取 | 抽帧 → 逐帧 OCR → 汇总 SRT → LLM 过滤 | 提取烧录字幕做基准数据;前端框选 crop;LLM 过滤多余/无意义字幕 | + +最终产物按 `上传文件名.标识.时间戳` 重命名(如 `test01.zh-CN.20260815123000.srt`), +标识优先取节点的 `target_language` 参数,否则用产物别名。 + +### 任务参数覆盖(前端框选) + +创建任务时可携带可选 `params` 表单字段(JSON):`{"节点ID": {"参数": 值}}`, +随任务持久化(param_overrides),调度执行时合并进对应节点参数。字幕 OCR +前端把框选的 `crop` 按此传给 `frame-extract` 节点。 + +### 切换模型不改代码 + +- 模型是工作流 DAG 中 asr 节点的 `model_path` 参数(**数据**),两个内置工作流 + 均已显式声明:demo 用 `faster-whisper-large-v3`,zh-direct 用中文直出模型。 +- 切换模型 = 改 `workflows/*.json` 或管理页面 DAG JSON → 保存新版本 → 发布, + 全程不涉及代码;新库启动时从 JSON 重新 seed。 +- 默认工作流定义存放在 `workflows/*.json`(数据文件),代码只负责加载。 + +### 长音频处理 + +**当前策略:分块转写,默认每 1 分钟一块**(`chunk_seconds=60`,2026-08 调整)。 +whisper 节点内部用 ffmpeg 把音频切成块 → 逐块转写 → 按偏移合并为完整 SRT: + +- 内存/显存有界(模型 + 单块音频),任意时长可处理,失败粒度小。 +- 分块是**应用层工程策略**,与模型训练格式无关:whisper 训练/推理都按 30s + 窗口解码,任意块大小均适用。 +- 每块 `offset = 块序号 × chunk_seconds`,SRT 序号连续;切块失败自动回退 + 整段单次转写。 +- `chunk_seconds=0` 可关闭分块;大小按工作流 DAG 参数(数据)调整。 +- 同时默认 `condition_on_previous_text=false`(每块/每窗口独立解码,防重复)。 +- **`vad_filter` 默认开启**(2026-08 用户决定):过滤静音段提速并减少无语音处 + 幻觉。注意 VAD 靠压缩时间轴回映射(SpeechTimestampsMap),长静音场景曾实测 + 错位(30s 静音致第二段语音从 ~40s 落到 10s);如需极致对齐可显式传 + `vad_filter=false`。 +- **分块偏移按每块实际时长累积**(WAV 头精确):ffmpeg 切出的块实际时长不等于 + 块长(如 60.05s),用 `块序号×块长` 的假设值会随块数累积漂移;改为按真实 + 时长累加后,字幕时间轴与原始音频严格一致。 +- 参考:openai/whisper 重复问题(issue #1026/#1046,PR #1052/#1253)、 + SYSTRAN/faster-whisper issue #465。 + +### glm-ocr 重复循环问题与源头修复(2026-08) + +- **根因**:glm-ocr 生成阶段存在已知 bug(M-RoPE delta 未传递,大图触发 + 重复循环;GitHub #454 / #16892)。`keep_alive` 与其无关(实测无效)。 +- **源头修复**: + 1. `frame-extract` 裁切后把帧**压缩到 720p 内**(仅缩小,保持宽高比)—— + 过大输入图是触发条件之一。 + 2. `vlm` 请求体 `options.repeat_penalty`(默认 1.2)+ `num_predict` + (默认 256)压制重复。 + 3. `subtitle-ocr` 增加 `max_result_chars`(默认 200):模型输出超长视为 + 异常(重复循环等),**直接报错并跳过该帧**。 +- **glm-ocr 调用结构**:走 Ollama `/api/chat`,识别指令放**系统提示词**, + 用户消息只携带图片(content 为空、images 传 base64),`stream=True` + 逐行接收,`stop: ["\n", "\n答", "答"]` 命中即停止(输出首个换行即停 + 阻止“答:”式重复循环),`temperature` 默认 + 0.3、`repeat_penalty` 默认 1、`num_predict`(默认 256)随请求透传。 +- **gettext 标签防御性提取**(2026-08):若模型输出含 `` + 标签(旧提示词要求)则取第一个标签内文本,多个标签取第一个防重复循环; + 未按格式输出时回退原文。当前默认提示词为"提取图像中的文字,不要描述 + 图片中的内容"(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数 + 中显式指定,经 subtitle-ocr 透传给 vlm-ocr)。 +- **每次调用 5 秒上限**(2026-08 调整):vlm 请求 `stream=True` 逐行读取, + 每次调用整体受 5 秒截止时间约束(`timeout_seconds` 参数 / + `VLM_TIMEOUT_SECONDS`,默认 5),超过即终止返回 failed,不再等待后续 + 流式块。 +- **不做文本加工**:除协议要求的 gettext 标签提取外,不再对模型输出做 + 过滤/去重等文本加工,结果原样使用,仅受长度上限约束。 + +### VLM OCR 自适应并发(2026-08) + +subtitle-ocr 逐帧调 vlm-ocr 时使用 `nodes/adaptive_pool.py` 的自适应线程池 +弹性并发: + +- 从 `pool_min_workers`(默认 1)起步,按**滚动窗口**(`pool_window_seconds`, + 默认 10s)统计已完成任务的平均响应时间; +- 平均响应 < `pool_fast_threshold`(默认 0.3s)→ 线程数 +1(上限 + `pool_max_workers`,默认 16)——服务端空闲就加大并发加速处理; +- 平均响应 > `pool_slow_threshold`(默认 1.0s)→ 线程数 -1(下限 1)—— + 服务端变慢就退避,避免盲目并发压垮本地 Ollama; +- 结果按帧顺序返回,SRT 时间轴不受并发影响;worker 需无共享可变状态 + (vlm-ocr 处理器为纯函数,线程安全)。 +### 前端 OCR 框选 + +首页选择工作流后,若 DAG 中存在声明 `crop` 参数的节点(frame-extract), +自动切换到框选面板:视频预览 + 拖动框选字幕区域 → 生成 crop 比例 → +框选完成后才可提交(未框选时提交按钮禁用)。矩形↔crop 换算为纯函数 +(`web/assets/crop.js`,含 letterbox 处理),由 node 单测覆盖。 + +## 环境变量 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `WOV_DATA_DIR` | `<根>/data` | 数据目录 | +| `WOV_DB_PATH` | `<根>/data/wov.db` | SQLite 路径 | +| `WOV_STORAGE_DIR` | `<根>/data/storage` | 上传与产物根目录 | +| `WOV_AUTO_SEED` | `1` | 启动时创建 demo 工作流 | +| `WOV_SCHEDULER_ENABLED` | `1` | 启动后台调度器 | +| `WOV_SCHEDULER_INTERVAL_SECONDS` | `1.0` | 调度轮询间隔 | +| `WOV_CLEANUP_ENABLED` | `1` | 开启孤儿数据定时清理 | +| `WOV_CLEANUP_INTERVAL_SECONDS` | `3600` | 孤儿清理扫描周期(秒) | +| `WOV_CLEANUP_GRACE_SECONDS` | `3600` | 孤儿清理宽限期(秒) | +| `WHISPER_MODEL_PATH` | 见上 | 显式指定 whisper 模型路径 | +| `WHISPER_DEVICE` | `auto` | 转写设备 | +| `LLM_API_BASE` | `https://api.siliconflow.cn/v1/chat/completions` | LLM 兼容接口 | +| `LLM_API_KEY` | 空(读 `.env`) | SiliconFlow Bearer Key,存于 gitignored 的 `.env` | +| `LLM_MODEL` | `Qwen/Qwen3.6-35B-A3B` | LLM 模型名 | +| `LLM_TIMEOUT_SECONDS` | `600` | LLM 单请求超时 | +| `OLLAMA_HOST` | `http://192.168.123.70:11434` | Ollama 服务地址 | +| `VLM_MODEL` | `glm-ocr:latest` | VLM OCR 模型 | +| `VLM_PROMPT` | 提取图像中的文字,不要描述图片中的内容 | OCR 提示词(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数中显式指定同一提示词) | +| `VLM_TIMEOUT_SECONDS` | `5` | VLM 单请求整体超时上限(每次调用 5 秒,超时即终止;流式读取同样受此截止约束) | +| `FFMPEG_BIN` | 空 | 显式 ffmpeg 路径(否则 PATH → imageio-ffmpeg) | + +## 启动 + +```bash +uv sync +uv run uvicorn wov_app.main:app --reload +``` + +访问: + +``` +http://127.0.0.1:8000/ 应用中心(上传视频 → 字幕生成) +http://127.0.0.1:8000/tasks.html 任务管理 +http://127.0.0.1:8000/admin.html 管理后台(工作流) +http://127.0.0.1:8000/workflow.html 工作流编排(DAG JSON) +http://127.0.0.1:8000/docs API 文档 +``` + +## Python 环境与 uv 管理 + +- 统一使用 uv 管理虚拟环境和依赖,禁止直接使用 pip 修改依赖。 +- 基础命令:`uv sync`(安装含 dev 组依赖)、`uv run `、 + `uv add `、`uv lock`。 +- 虚拟环境位于 `.venv`,测试依赖在 `[dependency-groups] dev`。 +- 新增依赖时使用 `uv add`,不修改系统 Python 或全局环境。 + +## 孤儿数据清理 + +应用内置后台清理器(`src/wov_app/maintenance.py`),按周期自动清理死数据: + +- **自动删除**:无任务记录的上传/步骤残留目录;COMPLETED 且产物文件全部丢失、 + 超过宽限期(默认 1 小时)的任务记录(下载已全部 404)。 +- **绝不自动删除**:FAILED 任务(可重试)、QUEUED/RUNNING 任务、宽限期内的任务、 + 仍有产物文件的任务。 +- 手动删除任务仅通过删除接口(`DELETE /api/runs/{run_id}`)或管理界面进行。 + +## 任务暂停/继续(2026-08) + +- **状态机**:`QUEUED / RUNNING / PAUSED / COMPLETED / FAILED`。排队中或运行中的 + 任务可暂停(`POST /api/runs/{run_id}/pause`),PAUSED 可继续 + (`POST /api/runs/{run_id}/resume` → 恢复 QUEUED)。 +- **调度器语义**:`next_queued_run` 同时取 QUEUED 与 PAUSED;`execute_run` 在每个 + 节点边界检查状态,被暂停则停下保持 PAUSED(当前节点执行完后才停); + 继续时从产物表(`restore_run_outputs`,剥去"节点ID."前缀还原输出名)重建已完成 + 节点的输出,**跳过已完成节点断点续跑**,最后补做 final_outputs 收尾。 +- **前端**:任务管理页为 QUEUED/RUNNING 提供"暂停"、PAUSED 提供"继续"按钮。 +- **进度日志(数据处理速度)**: + - 调度器:每节点完成打印"任务 X 进度 i/N 节点: Y 耗时 Zs, 运行累计 Ws"; + - subtitle-ocr:`OCR 进度 X/Y 帧 (Z 帧/s)`;llm-filter:`字幕判定进度 X/Y 条 (Z 条/s)` + (线程池 `on_progress` 回调,每任务完成触发); + - whisper:分块转写打印"分块 X/Y 完成 offset=... 耗时 Zs (Nx 实时, 累计 ...s)"。 + +## 测试与覆盖率 + +- 必须达到 100% 行覆盖率(pytest 已配置 `--cov-fail-under=100`,范围 + `src/` 与 `nodes/`)。 +- 测试必须调用真实代码路径,不得在测试类中重写业务逻辑来模拟被测功能。 +- **测试必须使用真实数据**:真实音频(合法 WAV/PCM)、真实 JSON/数据库/文件; + 禁止用占位字节(如 `b"x"`)或伪造结构冒充被测数据——假数据测试只能凑覆盖率, + 无法验证真实行为,视为无意义测试。 +- 只允许在 I/O 边界使用 mock/stub:文件系统、网络、子进程、环境变量、时间、 + **模型推理**(重模型不进入单元测试;注入的假模型必须返回结构真实的分段, + 且必须配套真实模型集成测试,见下)。 +- **真实模型集成测试**:使用真实 faster-whisper 模型 + 真实音频素材验证 + 端到端转写(`tests/test_integration_whisper.py`);本地无模型或素材时跳过, + 有则必须执行,作为对假模型单测的校准。 +- **测试资产存放 `testdata/`**:图片(`ocr_text.png`)、语音(`speech_60s.wav`) + 等测试媒体一次性生成后入库,测试直接复用,**禁止在测试执行时再生成**; + 缺失时测试跳过而非现场生成。大体积视频素材放 `data/testdata/`(gitignored)。OCR 相关资产: +`ocr_text.png`(有文字)、`ocr_notext.png`(无文字帧)、`subtitle_10s.mp4` +(烧录 SUB 001@1-4s / SUB 002@6-9s 的 10s 测试视频)、`test_real_hav_sub.png` +(真实视频字幕截图,VLM 集成测试期望识别出"还有没有什么困扰 或者奇怪的地方吗")。 +- **开发流程强制 TDD(红-绿-重构)**:任何新功能/修复必须先写失败测试(红), + 再实现最小代码让其通过(绿),最后重构保持整洁;不允许先写实现后补测试。 +- 测试运行:`uv run pytest`;全部测试位于 `tests/`。 +- 100% 行覆盖率只保证代码路径被覆盖,不覆盖端口占用、防火墙、权限等 + 外部环境状态;端口问题用启动检查、端口检查与 uvicorn 冒烟测试补充。 +- 本地出现 `WinError 10013` / `WinError 10048` 时,先用 + `netstat -ano | findstr :` 确认是否有残留监听进程。 + +## 代码注释规范 + +- 本仓库所有源码(Python、JavaScript、HTML、CSS、TOML 等支持注释的文件) + 必须配有详细中文注释,说明模块/文件职责、核心类与函数的作用以及关键逻辑, + 确保后续维护人员无需通读全部实现即可快速理解工作原理。 +- 新增或修改代码时,必须同步补充或更新对应注释;不得删除已有注释。 +- 测试代码同样必须配有中文注释,说明每条测试验证的行为与覆盖的路径。 +- JSON 数据文件(`manifests/*.json`)按 JSON 规范不支持注释,字段语义以 + `src/wov_sdk/models.py` 的 `NodeManifest` 模型注释和本文档为准; + 修改 JSON 字段时须同步更新文档。 + +## 目标运行环境 + +- 本服务的最终部署目标是 Linux,通常以 Docker/Kubernetes 容器运行。 +- 当前 Windows 只作为本地开发环境,不允许在业务代码中写死 Windows 路径、 + 盘符或 Windows 专用命令。 +- 路径处理统一使用 `pathlib`。 +- ffmpeg 在 Linux 上可使用系统包,也允许通过 `imageio-ffmpeg` 使用内置 + 二进制,节点代码不能假设 ffmpeg 一定在 PATH。 +- 测试必须可以在 Windows 和 Linux 上运行;涉及平台分支的代码应同时覆盖 + 两种路径解析。 + +## Windows / PowerShell 执行规则 + +- 默认 shell 视为 Windows PowerShell 5.1;不要假设 Bash、zsh 或 PowerShell 7。 + 必要时先查 `$PSVersionTable.PSVersion`。 +- 禁止把 Bash 语法交给 PowerShell:`python - <<'PY'`、`cat <<$` 或引号时默认用单引号。 +- 外部程序路径可能有空格时,用 `& 'C:\path with spaces\tool.exe' arg1`。 +- 文件操作优先 PowerShell 原生命令和 `-LiteralPath`。 +- 复杂 Python 不用 `python -c`;涉及 SQL、JSON、中文、反斜杠路径、换行或 + 多层引号时,用仓库脚本或临时 `.py` 文件。 +- 禁止在 PowerShell 用 Bash here-doc。临时传 Python 源码只允许 PowerShell + here-string,且尽量保持 ASCII。 +- Python 源码含中文常量时,不通过 PowerShell 管道传给 `python -`;用 UTF-8 + 脚本文件、仓库脚本或 `\uXXXX`。 +- 搜索文本/文件优先 `rg` / `rg --files`。 +- 数据库或生产内容写操作前先查询当前数据;写入必须有明确筛选条件,禁止 + 无条件 `DELETE` / `UPDATE`。 +- 同一 PowerShell 命令连续失败两次后,停止微调长命令;改短命令、脚本文件、 + 数组 splatting 或分步验证。 + +## 关键设计约束(北极星不变式) + +- 节点之间不直接调用,只通过产物 URI 交换数据;中间产物落在共享存储 + (`data/storage`),不放在节点模块内部。 +- 工作流必须是数据文件或数据库记录(workflow_versions 表存 DAG JSON), + 不允许把步骤顺序写死在应用代码里。 +- 节点注册表是节点调用的唯一入口;API 与调度器不绕过 registry 直接执行 + 节点逻辑。 +- 协议数据模型(wov_sdk)要长期稳定,宁可先少做功能,也不轻易改协议。 +- 存储、队列、调度器都要通过抽象边界隔离,方便从单机实现替换为分布式实现。 +- 用户端永远只看到"输入 -> 进度 -> 结果",不暴露工作流细节。 +- 单体对分布式版的三处降级:无子进程隔离、无空闲 TTL 回收(模型常驻, + 仅懒加载)、慢任务无法强制中断(由节点自身超时兜底)。 + +## 单体化说明 + +- 由原 7 个独立仓库合并:wov-api、wov-web、wov-sdk、wov-node-echo、 + wov-node-ffmpeg、wov-node-whisper、wov-node-llm、wov-node-ass。 +- 删除内容:`NodeManager`(子进程生命周期)、节点 HTTP 服务端 + (`wov_sdk.server`)、节点注册/实例管理 API 与页面、node_instances 表、 + 各节点的 `__main__` 进程入口。 +- 保留内容:协议数据模型、工作流 DAG 数据化、调度拓扑执行、上传/进度/下载/ + 重试 API、静态前端、SQLite Repository 层、本地优先模型加载。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d01116b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# VRSub + +为视频生成 **VR 双眼字幕**的单体应用:API、调度器与全部节点在同一进程内运行, +单仓库、单环境、单命令启动。由原分布式 WOV 多仓库合并而来,详细约定见 +[AGENTS.md](./AGENTS.md)。 + +## 快速开始 + +```bash +uv sync +uv run uvicorn wov_app.main:app --reload +``` + +访问 `http://127.0.0.1:8000/` 上传视频,自动执行"提音 → 转写 → 翻译 → ASS"。 + +- 模型权重本地优先:把 whisper 模型放在 `model/faster-whisper-large-v3` + (含 `model.bin`)即可完全离线运行。 +- LLM 翻译默认使用 SiliconFlow(`Qwen/Qwen3.6-35B-A3B`),API Key 放在 + gitignored 的 `.env`(`LLM_API_KEY=sk-...`),启动时自动加载;也可用 + `LLM_API_BASE` / `LLM_MODEL` 环境变量覆盖。 + +## 目录 + +| 路径 | 说明 | +| --- | --- | +| `src/wov_sdk/` | 协议数据模型(与分布式版兼容) | +| `src/wov_app/` | 应用层:API、注册表、调度器、数据库 | +| `nodes/` | 进程内节点实现(echo/ffmpeg/whisper/llm/ass) | +| `manifests/` | 节点清单 JSON | +| `web/` | 静态前端 | +| `model/` | 本地模型权重(gitignored) | +| `data/` | SQLite 与存储(gitignored) | +| `tests/` | 测试(100% 行覆盖率) | + +## 测试 + +```bash +uv run pytest +``` + +## 与分布式版的关系 + +- 协议数据模型、工作流 DAG 数据化、调度拓扑执行保持不变。 +- 已移除:子进程节点、节点 HTTP 协议、节点注册/实例管理、TTL 回收。 +- 未来回退分布式时,只需为 `registry.invoke` 重新加上进程边界。 diff --git a/manifests/ass.json b/manifests/ass.json new file mode 100755 index 0000000..8417ff9 --- /dev/null +++ b/manifests/ass.json @@ -0,0 +1,21 @@ +{ + "id": "srt-to-dual-eye-ass", + "name": "SRT to Dual-Eye ASS", + "version": "0.1.0", + "capability": "subtitle", + "repo_dir": "wov-node-ass", + "command": ["python", "-m", "wov_node_ass"], + "env": { + "WOV_NODE_PORT": "0" + }, + "input_schema": { + "cn_srt_uri": "file" + }, + "output_schema": { + "ass_uri": "file" + }, + "max_concurrency": 1, + "idle_ttl_seconds": 60, + "health_timeout_seconds": 10, + "keep_warm": false +} diff --git a/manifests/echo.json b/manifests/echo.json new file mode 100755 index 0000000..2838ca3 --- /dev/null +++ b/manifests/echo.json @@ -0,0 +1,22 @@ +{ + "id": "echo", + "name": "Echo Node", + "version": "0.1.0", + "capability": "echo", + "repo_dir": "wov-node-echo", + "command": ["python", "-m", "wov_node_echo"], + "env": { + "WOV_NODE_PORT": "0" + }, + "input_schema": { + "text": "string" + }, + "output_schema": { + "text": "string", + "file_uri": "file" + }, + "max_concurrency": 1, + "idle_ttl_seconds": 15, + "health_timeout_seconds": 10, + "keep_warm": false +} diff --git a/manifests/ffmpeg.json b/manifests/ffmpeg.json new file mode 100755 index 0000000..9537277 --- /dev/null +++ b/manifests/ffmpeg.json @@ -0,0 +1,21 @@ +{ + "id": "ffmpeg-extract", + "name": "FFmpeg Audio Extract", + "version": "0.1.0", + "capability": "media", + "repo_dir": "wov-node-ffmpeg", + "command": ["python", "-m", "wov_node_ffmpeg"], + "env": { + "WOV_NODE_PORT": "0" + }, + "input_schema": { + "video_uri": "file" + }, + "output_schema": { + "audio_uri": "file" + }, + "max_concurrency": 1, + "idle_ttl_seconds": 60, + "health_timeout_seconds": 10, + "keep_warm": false +} diff --git a/manifests/frame-extract.json b/manifests/frame-extract.json new file mode 100644 index 0000000..bc725b4 --- /dev/null +++ b/manifests/frame-extract.json @@ -0,0 +1,15 @@ +{ + "id": "frame-extract", + "name": "Frame Extract", + "version": "0.1.0", + "capability": "frame-extract", + "repo_dir": "nodes", + "command": ["python", "-m", "frame_extract"], + "env": {}, + "input_schema": { "video_uri": "file" }, + "output_schema": { "frames_manifest": "file", "frame_count": "integer" }, + "max_concurrency": 1, + "idle_ttl_seconds": 300, + "health_timeout_seconds": 10, + "keep_warm": false +} diff --git a/manifests/llm-filter.json b/manifests/llm-filter.json new file mode 100644 index 0000000..80b8e73 --- /dev/null +++ b/manifests/llm-filter.json @@ -0,0 +1,15 @@ +{ + "id": "llm-filter", + "name": "LLM Subtitle Filter", + "version": "0.1.0", + "capability": "llm-filter", + "repo_dir": "nodes", + "command": ["python", "-m", "llm_filter"], + "env": {}, + "input_schema": { "srt_uri": "file" }, + "output_schema": { "srt_uri": "file", "kept": "integer", "removed": "integer" }, + "max_concurrency": 1, + "idle_ttl_seconds": 300, + "health_timeout_seconds": 10, + "keep_warm": false +} diff --git a/manifests/llm.json b/manifests/llm.json new file mode 100755 index 0000000..86d2670 --- /dev/null +++ b/manifests/llm.json @@ -0,0 +1,21 @@ +{ + "id": "llm-translate", + "name": "LLM Subtitle Translate", + "version": "0.1.0", + "capability": "llm", + "repo_dir": "wov-node-llm", + "command": ["python", "-m", "wov_node_llm"], + "env": { + "WOV_NODE_PORT": "0" + }, + "input_schema": { + "srt_uri": "file" + }, + "output_schema": { + "cn_srt_uri": "file" + }, + "max_concurrency": 1, + "idle_ttl_seconds": 60, + "health_timeout_seconds": 10, + "keep_warm": false +} diff --git a/manifests/subtitle-ocr.json b/manifests/subtitle-ocr.json new file mode 100644 index 0000000..0dab369 --- /dev/null +++ b/manifests/subtitle-ocr.json @@ -0,0 +1,15 @@ +{ + "id": "subtitle-ocr", + "name": "Subtitle OCR", + "version": "0.1.0", + "capability": "subtitle-ocr", + "repo_dir": "nodes", + "command": ["python", "-m", "subtitle_ocr"], + "env": {}, + "input_schema": { "frames_manifest": "file" }, + "output_schema": { "srt_uri": "file", "count": "integer" }, + "max_concurrency": 1, + "idle_ttl_seconds": 300, + "health_timeout_seconds": 10, + "keep_warm": false +} diff --git a/manifests/vlm.json b/manifests/vlm.json new file mode 100644 index 0000000..8d9043e --- /dev/null +++ b/manifests/vlm.json @@ -0,0 +1,15 @@ +{ + "id": "vlm-ocr", + "name": "VLM OCR", + "version": "0.1.0", + "capability": "ocr", + "repo_dir": "nodes", + "command": ["python", "-m", "vlm_ocr"], + "env": {}, + "input_schema": { "image_uri": "file" }, + "output_schema": { "text": "string", "text_uri": "file" }, + "max_concurrency": 1, + "idle_ttl_seconds": 300, + "health_timeout_seconds": 10, + "keep_warm": false +} diff --git a/manifests/whisper.json b/manifests/whisper.json new file mode 100755 index 0000000..e26b636 --- /dev/null +++ b/manifests/whisper.json @@ -0,0 +1,21 @@ +{ + "id": "faster-whisper", + "name": "Faster Whisper ASR", + "version": "0.1.0", + "capability": "asr", + "repo_dir": "wov-node-whisper", + "command": ["python", "-m", "wov_node_whisper"], + "env": { + "WOV_NODE_PORT": "0" + }, + "input_schema": { + "audio_uri": "file" + }, + "output_schema": { + "srt_uri": "file" + }, + "max_concurrency": 1, + "idle_ttl_seconds": 300, + "health_timeout_seconds": 30, + "keep_warm": false +} diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..5eec7c1 --- /dev/null +++ b/nodes/__init__.py @@ -0,0 +1,9 @@ +"""进程内节点实现包。 + +每个模块对应一个节点,提供统一的 invoke(request) -> InvokeResponse 处理器, +由 wov_app.registry 在启动时静态注册,调度器按 node_type 直接调用。 +""" + +from nodes import ass, echo, ffmpeg, llm, whisper + +__all__ = ["ass", "echo", "ffmpeg", "llm", "whisper"] diff --git a/nodes/adaptive_pool.py b/nodes/adaptive_pool.py new file mode 100644 index 0000000..14a2bb8 --- /dev/null +++ b/nodes/adaptive_pool.py @@ -0,0 +1,177 @@ +"""自适应线程池。 + +用于对耗时的独立子任务(如逐帧 VLM OCR)做弹性并发加速: +- 以滚动时间窗口统计已完成任务的平均响应时间; +- 窗口内平均响应 < fast_threshold(默认 0.3s)→ 增加 1 个工作线程(上限 max_workers); +- 窗口内平均响应 > slow_threshold(默认 1.0s)→ 减少 1 个工作线程(下限 min_workers)。 + +线程数从 min_workers(默认 1)起步,按实测负载自适应:服务端空闲(响应快) +就加大并发,服务端变慢就退避,避免盲目并发压垮上游(如本地 Ollama)。 + +线程安全说明:worker 会在多个线程中并发调用,调用方需保证 worker 无共享 +可变状态(registry 处理器是纯函数,符合要求);结果按输入顺序返回。 +""" + +from __future__ import annotations + +import queue +import threading +import time +from typing import Callable + +# 停止哨兵:压入队列让空闲工作线程退出(用于缩容)。 +_POISON = object() + + +def decide( + current: int, + avg: float, + min_workers: int, + max_workers: int, + fast_threshold: float, + slow_threshold: float, +) -> int: + """根据窗口平均响应时间返回调整后的目标线程数(纯决策函数)。 + + 响应快(avg < fast_threshold)且未达上限 → 加 1;响应慢 + (avg > slow_threshold)且未达下限 → 减 1;其余情况保持不变。 + """ + if avg < fast_threshold and current < max_workers: + return current + 1 + if avg > slow_threshold and current > min_workers: + return current - 1 + return current + + +class AdaptiveThreadPool: + """自适应线程池:单次 map 按输入顺序返回全部结果。""" + + def __init__( + self, + worker: Callable, + min_workers: int = 1, + max_workers: int = 16, + window_seconds: float = 10.0, + fast_threshold: float = 0.3, + slow_threshold: float = 1.0, + clock=time.monotonic, + on_progress: Callable[[int, int, float], None] | None = None, + ) -> None: + """初始化;clock 可注入便于测试;on_progress(done,total,rate) 每次完成回调。""" + self._worker = worker + self.min_workers = max(1, min_workers) + self.max_workers = max(self.min_workers, max_workers) + self.window_seconds = window_seconds + self.fast_threshold = fast_threshold + self.slow_threshold = slow_threshold + self._clock = clock + self._queue: queue.Queue = queue.Queue() + # 并发目标线程数:决策/缩容的权威依据(线程退出是异步的,不能用 + # len(_threads) 判断,否则并发缩容会重复放哨兵把全部线程毒死)。 + self._target_workers = 0 + self._threads: list[threading.Thread] = [] + self._results: list = [] + self._lock = threading.Lock() + self._stop = threading.Event() + # 滚动窗口起点与已记录的单次耗时。 + self._window_start = clock() + # 观测到的最大并发线程数(供测试与监控)。 + self.max_concurrency = 0 + # 进度回调与计数:on_progress(已完成数, 总数, 平均速度/秒)。 + self._on_progress = on_progress + self._completed = 0 + self._total = 0 + self._started_at = 0.0 + self._window_times: list[float] = [] + + def _run(self) -> None: + """工作线程主循环:取任务 → 执行 → 记录耗时并自适应评估。""" + try: + while not self._stop.is_set(): + try: + seq, item = self._queue.get(timeout=0.2) + except queue.Empty: + continue + if item is _POISON: + # 缩容哨兵:处理完即可退出(队列计数照常)。 + self._queue.task_done() + break + start = self._clock() + try: + result = self._worker(item) + except Exception as exc: + # 单任务异常不拖垮整体:以异常对象作为结果,由调用方判定。 + result = exc + finally: + elapsed = self._clock() - start + self._results.append((seq, result)) + # 进度回调:已完成数、总数与平均处理速度(条/秒)。 + self._completed += 1 + if self._on_progress is not None: + elapsed_total = max(self._clock() - self._started_at, 1e-9) + self._on_progress( + self._completed, self._total, self._completed / elapsed_total + ) + self._tick(elapsed) + self._queue.task_done() + finally: + # 无论何种退出路径都从线程列表移除,保证线程数统计准确。 + with self._lock: + if threading.current_thread() in self._threads: + self._threads.remove(threading.current_thread()) + + def _tick(self, elapsed: float) -> None: + """记录一次完成耗时;窗口满时按平均响应时间调整线程数。""" + self._window_times.append(elapsed) + if self._clock() - self._window_start < self.window_seconds: + return + avg = sum(self._window_times) / len(self._window_times) + self._window_start = self._clock() + self._window_times.clear() + with self._lock: + current = self._target_workers + self._resize( + decide( + current, avg, self.min_workers, self.max_workers, + self.fast_threshold, self.slow_threshold, + ) + ) + + def _resize(self, target: int) -> None: + """调整并发目标:扩容启动新线程;缩容压入等量停止哨兵(幂等)。 + + 以 _target_workers 为当前值:重复调用同一 target 不会重复放哨兵, + 避免并发缩容把所有线程毒死导致队列任务无人处理而挂起。 + """ + with self._lock: + current = self._target_workers + if target > current: + self.max_concurrency = max(self.max_concurrency, target) + for _ in range(target - current): + thread = threading.Thread(target=self._run, daemon=True) + thread.start() + self._threads.append(thread) + self._target_workers = target + elif target < current: + for _ in range(current - target): + self._queue.put((None, _POISON)) + self._target_workers = target + + def map(self, items) -> list: + """按输入顺序返回每个 item 经 worker 处理后的结果列表。""" + self._results = [] + self._completed = 0 + self._total = len(items) + self._started_at = self._clock() + self._stop.clear() + self._resize(self.min_workers) + for seq, item in enumerate(items): + self._queue.put((seq, item)) + self._queue.join() + self._stop.set() + with self._lock: + threads = list(self._threads) + for thread in threads: + thread.join(1.0) + self._results.sort(key=lambda pair: pair[0]) + return [result for _, result in self._results] diff --git a/nodes/ass.py b/nodes/ass.py new file mode 100755 index 0000000..7417e6a --- /dev/null +++ b/nodes/ass.py @@ -0,0 +1,98 @@ +"""SRT 转 ASS 节点。 + +单体版中作为进程内节点模块,由调度器直接调用。解析标准 SRT 后生成 ASS +文件,其中同一句字幕同时输出 LeftEye 与 RightEye 两个样式,分别落在屏幕 +左右两半,形成 VR 双眼叠加效果。 +""" + +from __future__ import annotations + +from pathlib import Path + +from wov_sdk.models import InvokeRequest, InvokeResponse + +def _ass_header(resolution: str) -> str: + """生成 ASS 文件头:脚本信息、左右眼样式和事件格式。""" + width, height = resolution.lower().split("x", 1) + # 左眼样式占左半边,右眼样式占右半边,各留 50px 内边距。 + left_margin = 50 + right_margin = int(width) - 50 + return f"""[Script Info] +Title: VR Dual-Eye Subtitle +ScriptType: v4.00+ +Collisions: Normal +PlayResX: {width} +PlayResY: {height} +WrapStyle: 1 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding +Style: LeftEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,{left_margin},{int(width) // 2},{int(height) // 2 + 60},1 +Style: RightEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,{int(width) // 2},{right_margin},{int(height) // 2 + 60},1 + +[Events] +Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text +""" + + +def parse_srt(text: str) -> list[tuple[str, str, str]]: + """把 SRT 文本解析为 (开始时间, 结束时间, 文本) 条目列表。""" + entries: list[tuple[str, str, str]] = [] + lines = text.splitlines() + index = 0 + while index < len(lines): + # 跳过序号前的空行,兼容文件开头有换行的情况。 + if not lines[index].strip(): + index += 1 + continue + # 跳过序号行,直接读取下一行时间轴。 + index += 1 + if index >= len(lines): + break + time_line = lines[index].strip() + index += 1 + # 时间轴必须包含分隔符,否则按畸形输入跳过。 + if " --> " not in time_line: + continue + # SRT 使用逗号毫秒,ASS 使用点号,需要转换。 + start, end = [part.replace(",", ".") for part in time_line.split(" --> ")] + # 连续读取非空行作为字幕文本,多行用 ASS 换行符 \N 连接。 + text_lines: list[str] = [] + while index < len(lines) and lines[index].strip(): + text_lines.append(lines[index]) + index += 1 + entries.append((start, end, r"\N".join(text_lines))) + index += 1 + return entries + + +def write_ass(entries: list[tuple[str, str, str]], output_path: Path, resolution: str) -> None: + """把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。""" + lines = [_ass_header(resolution)] + for start, end, text in entries: + # an2 对齐到屏幕中央偏下,保证双眼字幕视线自然。 + lines.append(f"Dialogue: 0,{start},{end},LeftEye,,0,0,0,,{{\\an2}}{text}") + lines.append(f"Dialogue: 0,{start},{end},RightEye,,0,0,0,,{{\\an2}}{text}") + output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def invoke(request: InvokeRequest) -> InvokeResponse: + """把 cn_srt_uri 指向的 SRT 转为 dual_eye.ass 产物。""" + srt_uri = request.inputs.get("cn_srt_uri") + if not srt_uri: + return InvokeResponse(status="failed", error="cn_srt_uri is required") + + srt_path = Path(srt_uri) + if not srt_path.is_file(): + return InvokeResponse(status="failed", error="srt file not found") + + entries = parse_srt(srt_path.read_text(encoding="utf-8")) + output_dir = Path(request.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "dual_eye.ass" + # 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。 + resolution = str(request.params.get("resolution", "3840x1920")) + write_ass(entries, output_path, resolution) + return InvokeResponse(status="completed", outputs={"ass_uri": str(output_path)}) + diff --git a/nodes/echo.py b/nodes/echo.py new file mode 100755 index 0000000..898cdfd --- /dev/null +++ b/nodes/echo.py @@ -0,0 +1,50 @@ +"""Echo 节点。 + +单体版中作为进程内节点模块存在,由调度器直接调用 invoke 处理器,不再启动 +独立 HTTP 服务。保留该节点用于验证节点协议与注册表链路。 +""" + +from __future__ import annotations + +from pathlib import Path + +from wov_sdk.models import InvokeRequest, InvokeResponse + +def _resolve_input_text(request: InvokeRequest, node_root: Path) -> str: + """按优先级解析输入文本:直接文本 > 文件 URI > 默认字符串。""" + # 优先使用请求中直接携带的 text 字段。 + text = request.inputs.get("text") + if text is not None: + return str(text) + + # 其次读取 file_uri 指向的文件;相对路径以单体根目录为基准。 + file_uri = request.inputs.get("file_uri") + if file_uri: + path = Path(file_uri) + if not path.is_absolute(): + path = node_root / path + return path.read_text(encoding="utf-8") + + # 都没有时返回固定文本,保证节点总有可演示的输出。 + return "echo" + + +def invoke(request: InvokeRequest) -> InvokeResponse: + """处理节点调用:把解析出的文本写入产物并返回 URI。""" + # 单体根目录用于解析相对文件路径(nodes/ 的上一级)。 + node_root = Path(__file__).resolve().parent.parent + output_dir = Path(request.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + text = _resolve_input_text(request, node_root) + # 产物必须落在请求给定的 output_dir,调度器按 run 与节点组织目录。 + output_path = output_dir / "echo.txt" + output_path.write_text(text, encoding="utf-8") + + return InvokeResponse( + status="completed", + outputs={ + "text": text, + "file_uri": str(output_path), + }, + ) diff --git a/nodes/ffmpeg.py b/nodes/ffmpeg.py new file mode 100755 index 0000000..23db1bc --- /dev/null +++ b/nodes/ffmpeg.py @@ -0,0 +1,79 @@ +"""FFmpeg 提音节点。 + +单体版中作为进程内节点模块,由调度器直接调用。ffmpeg 解析顺序为: +FFMPEG_BIN 环境变量 > PATH 中的 ffmpeg > imageio-ffmpeg 内置二进制。 +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +from wov_sdk.models import InvokeRequest, InvokeResponse + +def _bundled_ffmpeg() -> str | None: + """尝试获取 imageio-ffmpeg 内置的 ffmpeg 可执行文件路径。""" + try: + import imageio_ffmpeg + + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception: # noqa: BLE001 + # 未安装 imageio-ffmpeg 或获取失败时返回 None,交由上层回退。 + return None + + +def _ffmpeg_bin() -> str: + """按优先级解析 ffmpeg 可执行文件,返回最终命令路径。""" + # 显式配置优先,便于部署环境指定自定义二进制。 + configured = os.getenv("FFMPEG_BIN") + if configured: + return configured + # 其次查找 PATH 中的系统 ffmpeg。 + found = shutil.which("ffmpeg") + if found: + return found + # 最后回退到 imageio-ffmpeg 内置二进制;都没有时保留 "ffmpeg" 交给调用失败处理。 + return _bundled_ffmpeg() or "ffmpeg" + + +def invoke(request: InvokeRequest) -> InvokeResponse: + """提取输入视频/音频的标准化音频,产物为 audio.wav。""" + video_uri = request.inputs.get("video_uri") + if not video_uri: + return InvokeResponse(status="failed", error="video_uri is required") + + # 找不到可用 ffmpeg 时直接返回失败,避免子进程报晦涩错误。 + ffmpeg = _ffmpeg_bin() + if shutil.which(ffmpeg) is None and not Path(ffmpeg).is_file(): + return InvokeResponse(status="failed", error="ffmpeg not found") + + output_dir = Path(request.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "audio.wav" + # ASR 节点默认期望 16kHz 单声道;参数可覆盖。 + channels = str(request.params.get("channels", 1)) + sample_rate = str(request.params.get("sample_rate", 16000)) + command = [ + ffmpeg, + "-y", # 覆盖可能存在的同名输出文件。 + "-i", + str(video_uri), + "-vn", # 丢弃视频流,只保留音频。 + "-ac", + channels, + "-ar", + sample_rate, + str(output_path), + ] + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + # 返回 stderr 尾部,保留最有诊断价值的错误信息。 + return InvokeResponse( + status="failed", + error=result.stderr[-2000:] or "ffmpeg failed", + ) + return InvokeResponse(status="completed", outputs={"audio_uri": str(output_path)}) + + diff --git a/nodes/frame_extract.py b/nodes/frame_extract.py new file mode 100644 index 0000000..c2fdfb6 --- /dev/null +++ b/nodes/frame_extract.py @@ -0,0 +1,178 @@ +"""视频抽帧节点。 + +按**帧间隔**从视频抽取帧:解析视频帧率后,帧间隔 step = round(间隔秒 × fps), +用 ffmpeg 的 select 过滤器按帧号(n mod step == 0)精确取帧——每帧都是真实 +视频帧,帧时间 = 帧号 / fps,避免按时间 seek(-ss 秒)造成的取整漂移。 +再按 crop 相对比例 [x,y,w,h](0~1)裁切出字幕区域,供 vlm-ocr 等下游节点 +使用。产物为 frames.json 清单:[{"time": 帧时间秒, "image_uri": 帧图片路径}, ...]。 +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +from nodes.ffmpeg import _ffmpeg_bin +from wov_app.logging import get_logger +from wov_sdk.models import InvokeRequest, InvokeResponse + +logger = get_logger("frame-extract") + +# 默认裁切:画面底部 18% 区域(常见字幕位置)。 +DEFAULT_CROP = [0.0, 0.82, 1.0, 0.18] + + +def _parse_crop(raw) -> list[float] | None: + """解析并校验 crop 相对比例 [x,y,w,h](0~1 且不越出画面)。""" + try: + crop = [float(value) for value in raw] + except (TypeError, ValueError): + return None + if len(crop) != 4: + return None + x, y, w, h = crop + if not (0 <= x <= 1 and 0 <= y <= 1 and 0 <= w <= 1 and 0 <= h <= 1): + return None + if x + w > 1.001 or y + h > 1.001: + return None + return crop + + +def _video_size(video: Path, ffmpeg_bin: str) -> tuple[int, int] | None: + """从 ffmpeg -i 输出解析视频分辨率,避免依赖 ffprobe。""" + result = subprocess.run( + [ffmpeg_bin, "-i", str(video)], + capture_output=True, + text=True, + ) + for line in (result.stderr or "").splitlines(): + if "Video:" not in line: + continue + match = re.search(r"(\d{2,5})x(\d{2,5})", line) + if match: + return int(match.group(1)), int(match.group(2)) + return None + + +def _video_duration(video: Path, ffmpeg_bin: str) -> float | None: + """从 ffmpeg -i 输出解析总时长(秒)。""" + result = subprocess.run( + [ffmpeg_bin, "-i", str(video)], + capture_output=True, + text=True, + ) + match = re.search(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", result.stderr or "") + if match: + hours = int(match.group(1)) + minutes = int(match.group(2)) + seconds = float(match.group(3)) + return hours * 3600 + minutes * 60 + seconds + return None + + +def _video_fps(video: Path, ffmpeg_bin: str) -> float | None: + """从 ffmpeg -i 输出解析帧率,支持小数(29.97 fps)与有理数(30000/1001 fps)。""" + result = subprocess.run( + [ffmpeg_bin, "-i", str(video)], + capture_output=True, + text=True, + ) + for line in (result.stderr or "").splitlines(): + if "Video:" not in line: + continue + match = re.search(r"(\d+)/(\d+)\s*fps", line) + if match: + return int(match.group(1)) / int(match.group(2)) + match = re.search(r"(\d+(?:\.\d+)?)\s*fps", line) + if match: + return float(match.group(1)) + return None + + +def _frame_step(fps: float, interval: float) -> int: + """帧间隔换算:每 step 帧取一帧(step=round(间隔秒×fps),至少为 1)。""" + return max(1, int(round(interval * fps))) + + +def invoke(request: InvokeRequest) -> InvokeResponse: + """按帧间隔抽取并裁切视频帧,输出 frames.json 清单。""" + video_uri = request.inputs.get("video_uri") + if not video_uri: + return InvokeResponse(status="failed", error="video_uri is required") + video = Path(video_uri) + if not video.is_file(): + return InvokeResponse(status="failed", error="video file not found") + + interval = float(request.params.get("interval_seconds", 0.5)) + crop = _parse_crop(request.params.get("crop", DEFAULT_CROP)) + if crop is None: + return InvokeResponse(status="failed", error="invalid crop") + if interval <= 0: + return InvokeResponse(status="failed", error="invalid interval_seconds") + + ffmpeg_bin = _ffmpeg_bin() + size = _video_size(video, ffmpeg_bin) + if size is None: + return InvokeResponse(status="failed", error="cannot read video size") + duration = _video_duration(video, ffmpeg_bin) + if duration is None or duration <= 0: + return InvokeResponse(status="failed", error="cannot read video duration") + fps = _video_fps(video, ffmpeg_bin) + if fps is None or fps <= 0: + return InvokeResponse(status="failed", error="cannot read video fps") + + # 把"每多少秒一帧"换算为"每多少帧取一帧",按帧号取帧是帧精确的。 + step = _frame_step(fps, interval) + width, height = size + x_px = int(round(crop[0] * width)) + y_px = int(round(crop[1] * height)) + w_px = max(1, int(round(crop[2] * width))) + h_px = max(1, int(round(crop[3] * height))) + + output_dir = Path(request.output_dir) + frames_dir = output_dir / "frames" + frames_dir.mkdir(parents=True, exist_ok=True) + # 单次解码全片:select 按帧号(n mod step == 0)精确取帧,随后对选中帧 + # 裁切字幕区域并压缩到 720p 内(仅缩小)——过大的输入图会触发 glm-ocr + # 的重复循环 bug(M-RoPE delta),源头规避。 + result = subprocess.run( + [ + ffmpeg_bin, + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + str(video), + "-vf", + ( + f"select='not(mod(n\\,{step}))'," + f"crop={w_px}:{h_px}:{x_px}:{y_px}," + "scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2" + ), + # 只写出被选中的帧,避免 CFR 补帧产生重复文件。 + "-vsync", + "vfr", + str(frames_dir / "frame_%04d.png"), + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return InvokeResponse(status="failed", error=result.stderr[-500:] or "ffmpeg failed") + + # 第 k 个输出文件对应原始帧号 k×step,时间 = 帧号 / fps(帧精确,无累计偏差)。 + files = sorted(frames_dir.glob("frame_*.png")) + manifest = [ + {"time": round((index * step) / fps, 3), "image_uri": str(path)} + for index, path in enumerate(files) + ] + manifest_path = output_dir / "frames.json" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + logger.info("抽帧完成: %d 帧, 帧间隔 %d, 裁切 %dx%d+%d+%d", len(files), step, w_px, h_px, x_px, y_px) + return InvokeResponse( + status="completed", + outputs={"frames_manifest": str(manifest_path), "frame_count": len(files)}, + ) diff --git a/nodes/llm.py b/nodes/llm.py new file mode 100755 index 0000000..a2126f1 --- /dev/null +++ b/nodes/llm.py @@ -0,0 +1,103 @@ +"""LLM 翻译节点。 + +单体版中作为进程内节点模块,由调度器直接调用。接收 SRT,提取纯文本行 +分批调用 LLM,再把译文回填到原 SRT 结构并输出 cn.srt。 +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from pathlib import Path + +from wov_sdk.models import InvokeRequest, InvokeResponse +# 单次 LLM 请求携带的字幕行数;过大会超出模型上下文,过小则请求次数过多。 +CHUNK_SIZE = 20 + + +def translate_lines(lines: list[str], params: dict) -> list[str]: + """分批调用 LLM 翻译纯文本行,返回顺序一致的译文列表。""" + # 接口地址、Key 和模型均可通过环境变量配置(.env 自动加载), + # 默认指向 SiliconFlow 兼容接口,模型为 DeepSeek-V4-Flash。 + api_base = os.getenv( + "LLM_API_BASE", + "https://api.siliconflow.cn/v1/chat/completions", + ) + api_key = os.getenv("LLM_API_KEY", "") + # 单次请求超时可配置,长文本翻译场景下需要放宽。 + request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "600")) + model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B")) + target_language = str(params.get("target_language", "zh-CN")) + # 系统提示词约束模型只输出译文,保证行数和顺序可回填。 + system_prompt = ( + "你是专业字幕翻译。将用户提供的日文字幕翻译为" + f"{target_language}。只返回译文,保持行数和顺序,不要添加解释。" + ) + translated: list[str] = [] + # 按 CHUNK_SIZE 分批发送,避免单次请求超过模型上下文限制。 + for start in range(0, len(lines), CHUNK_SIZE): + chunk = lines[start : start + CHUNK_SIZE] + body = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "\n".join(chunk)}, + ], + # 关闭推理模型的思考模式:Qwen3 等模型默认会把推理过程写入 + # reasoning_content,导致 content 为空或截断译文;关闭后直接输出译文。 + "enable_thinking": False, + # 放宽输出上限,避免长批次翻译被模型默认 max_tokens 截断。 + "max_tokens": 8192, + } + headers = {"Content-Type": "application/json"} + # 配置了 Key 时附带 Bearer 鉴权头。 + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + request = urllib.request.Request( + api_base, + data=json.dumps(body).encode("utf-8"), + headers=headers, + method="POST", + ) + with urllib.request.urlopen(request, timeout=request_timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + # 兼容 OpenAI Chat Completions 响应格式,取第一条消息内容。 + content = payload["choices"][0]["message"]["content"] + # 忽略空行,保证译文列表与输入行一一对应。 + translated.extend( + [line.strip() for line in content.splitlines() if line.strip()] + ) + return translated + + +def invoke(request: InvokeRequest) -> InvokeResponse: + """翻译 SRT 文件中的字幕文本,输出 cn.srt。""" + srt_uri = request.inputs.get("srt_uri") + if not srt_uri: + return InvokeResponse(status="failed", error="srt_uri is required") + + srt_path = Path(srt_uri) + if not srt_path.is_file(): + return InvokeResponse(status="failed", error="srt file not found") + + # 标准 SRT 每 4 行一组:序号、时间轴、文本、空行;文本位于第 3 行。 + lines = srt_path.read_text(encoding="utf-8").splitlines() + text_indices = list(range(2, len(lines), 4)) + source_lines = [lines[index] for index in text_indices] + translated_lines = translate_lines(source_lines, request.params) + # 防止模型返回行数偏差:多出的截断,缺少的用空串补齐。 + translated_lines = translated_lines[: len(source_lines)] + translated_lines += [""] * max(0, len(source_lines) - len(translated_lines)) + # 只替换文本行,序号、时间轴和空行保持不变。 + for index, text_index in enumerate(text_indices): + lines[text_index] = translated_lines[index] + + output_dir = Path(request.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "cn.srt" + # 末尾补一个换行,让文件满足常见文本工具习惯。 + output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return InvokeResponse(status="completed", outputs={"cn_srt_uri": str(output_path)}) + diff --git a/nodes/llm_filter.py b/nodes/llm_filter.py new file mode 100644 index 0000000..03151fb --- /dev/null +++ b/nodes/llm_filter.py @@ -0,0 +1,171 @@ +"""LLM 字幕过滤节点。 + +对 OCR 识别出的 SRT 字幕做二次过滤:为避免字幕上下文过长,把每条字幕连同其 +前后各 context_size 条字幕(纯文本,**不含时间戳**)分批提供给 LLM,模型仅判断 +目标字幕是否属于多余、无意义的字符(如重复、残缺、无实际语义的杂项); +判定无意义则删除该条(连同其时间戳),其余字幕保持原样并重新编号输出。 +""" + +from __future__ import annotations + +import json +import os +import re +import urllib.request +from pathlib import Path +from nodes.adaptive_pool import AdaptiveThreadPool +from wov_app.logging import get_logger +from wov_sdk.models import InvokeRequest, InvokeResponse + +logger = get_logger("llm-filter") + +# 匹配 SRT 条目:时间轴行 + 文本(文本可多行),到下一个序号行或文末结束。 +_SRT_BLOCK_RE = re.compile( + r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)", + re.DOTALL, +) + +# 目标字幕标记:提示词用该标记指明需要判断的那一条字幕。 +TARGET_MARK = "【目标】" + +# 默认上下文窗口:目标字幕前后各取 10 条。 +DEFAULT_CONTEXT_SIZE = 10 + + +def parse_srt(text: str) -> list[dict]: + """解析 SRT 文本为条目列表:[{"start", "end", "text"}]。""" + entries: list[dict] = [] + for match in _SRT_BLOCK_RE.finditer(text): + entries.append( + { + "start": match.group(1), + "end": match.group(2), + "text": match.group(3).strip(), + } + ) + return entries + + +def serialize_srt(entries: list[dict]) -> str: + """把条目列表序列化为标准 SRT 文本(序号重新从 1 编号)。""" + blocks = [ + f"{index}\n{entry['start']} --> {entry['end']}\n{entry['text']}" + for index, entry in enumerate(entries, start=1) + ] + return "\n\n".join(blocks) + "\n" + + +def _judge_target( + entries: list[dict], index: int, context_size: int, params: dict +) -> bool: + """调用 LLM 判断目标字幕是否多余/无意义;返回 True 表示应删除。 + + 请求体只含目标字幕及其前后各 context_size 条字幕的纯文本(无时间戳), + 目标字幕用 TARGET_MARK 标记;模型只需回答"保留"或"删除"。 + """ + start = max(0, index - context_size) + end = min(len(entries), index + context_size + 1) + target_pos = index - start + lines = [ + f"{TARGET_MARK}{text}" if pos == target_pos else text + for pos, text in enumerate(entry["text"] for entry in entries[start:end]) + ] + + # LLM 兼容接口配置:地址/Key/模型/超时均可通过环境变量覆盖(默认 SiliconFlow)。 + api_base = os.getenv( + "LLM_API_BASE", + "https://api.siliconflow.cn/v1/chat/completions", + ) + api_key = os.getenv("LLM_API_KEY", "") + request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "60")) + model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.6-35B-A3B")) + system_prompt = ( + "你是字幕质量过滤器。用户会提供一段字幕序列(纯文本,不含时间戳)," + f"其中用{TARGET_MARK}标记的字幕是需要判断的目标。请判断该字幕是否属于" + "多余、无意义的字符(如重复、残缺、无实际语义的杂项)。" + "只回答两个字:保留 或 删除,不要输出其他内容。" + ) + body = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "\n".join(lines)}, + ], + # 关闭推理模式:Qwen3 等模型默认会把思考过程写入 reasoning_content, + # 导致 content 为空或包含多余内容。 + "enable_thinking": False, + # 只输出"保留/删除",输出上限给得很小即可。 + "max_tokens": 16, + } + headers = {"Content-Type": "application/json"} + # 配置了 Key 时附带 Bearer 鉴权头。 + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + request = urllib.request.Request( + api_base, + data=json.dumps(body).encode("utf-8"), + headers=headers, + method="POST", + ) + with urllib.request.urlopen(request, timeout=request_timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + content = str(payload["choices"][0]["message"]["content"]) + # 模型回答含"删除"即视为该条无意义;其余情况(保留/异常)一律保留,宁多勿删。 + return "删除" in content + + +def invoke(request: InvokeRequest) -> InvokeResponse: + """过滤 SRT 中多余/无意义的字幕,产物为 filtered.srt。""" + srt_uri = request.inputs.get("srt_uri") + if not srt_uri: + return InvokeResponse(status="failed", error="srt_uri is required") + srt_path = Path(srt_uri) + if not srt_path.is_file(): + return InvokeResponse(status="failed", error="srt file not found") + + entries = parse_srt(srt_path.read_text(encoding="utf-8")) + context_size = int(request.params.get("context_size", DEFAULT_CONTEXT_SIZE)) + # 单条判断的工作函数:返回 True 表示该条应删除。 + def judge_one(index) -> bool: + return _judge_target(entries, index, context_size, request.params) + + # 进度日志:打印已判定条数、总数与平均处理速度(条/s)。 + def log_progress(done: int, total: int, rate: float) -> None: + logger.info("字幕判定进度 %d/%d 条 (%.1f 条/s)", done, total, rate) + + # 自适应并发调用 LLM:10s 窗口内平均响应 < 0.3s 则加 1 线程(上限 + # pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1), + # 按实测负载弹性伸缩,避免盲目并发压垮 LLM 接口。 + pool = AdaptiveThreadPool( + worker=judge_one, + on_progress=log_progress, + min_workers=int(request.params.get("pool_min_workers", 1)), + max_workers=int(request.params.get("pool_max_workers", 16)), + window_seconds=float(request.params.get("pool_window_seconds", 10.0)), + fast_threshold=float(request.params.get("pool_fast_threshold", 0.3)), + slow_threshold=float(request.params.get("pool_slow_threshold", 1.0)), + ) + verdicts = pool.map(range(len(entries))) + + kept: list[dict] = [] + removed = 0 + for index, (entry, verdict) in enumerate(zip(entries, verdicts)): + # 并行下 LLM 异常被线程池隔离为异常结果:任一条失败即整体失败, + # 避免静默输出未过滤结果。 + if isinstance(verdict, Exception): + return InvokeResponse(status="failed", error=str(verdict)) + if verdict: + removed += 1 + logger.info("删除无意义字幕 %d: %r", index + 1, entry["text"][:40]) + else: + kept.append(entry) + + output_dir = Path(request.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "filtered.srt" + output_path.write_text(serialize_srt(kept), encoding="utf-8") + logger.info("字幕过滤完成: 保留 %d 条, 删除 %d 条", len(kept), removed) + return InvokeResponse( + status="completed", + outputs={"srt_uri": str(output_path), "kept": len(kept), "removed": removed}, + ) diff --git a/nodes/subtitle_ocr.py b/nodes/subtitle_ocr.py new file mode 100644 index 0000000..9b8c46b --- /dev/null +++ b/nodes/subtitle_ocr.py @@ -0,0 +1,162 @@ +"""字幕 OCR 汇总节点。 + +读取 frame-extract 产出的 frames.json,逐帧调用 vlm-ocr 节点识别字幕文字; +过滤无文字帧的垃圾输出(glm-ocr 在空帧上会输出无用文字),折叠模型重复 +循环输出,合并连续相同的字幕(记录最后可见帧时间),最终组装为带时间轴的 +SRT 基准数据:每条字幕消失时间 = 最后可见帧时间 + 采样间隔(间隔从帧清单 +时间轴推导),与视频烧录时间对齐。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from nodes.adaptive_pool import AdaptiveThreadPool +from nodes.whisper import format_timestamp +from wov_app import registry +from wov_app.logging import get_logger +from wov_sdk.models import InvokeRequest, InvokeResponse + +logger = get_logger("subtitle-ocr") + +# 默认垃圾词:无文字帧的模型输出可能反复出现这些词。 +def _sampling_interval(manifest: list[dict], default: float) -> float: + """从帧清单时间轴推导采样间隔(相邻帧时间差的中位数)。 + + 帧时间由 frame-extract 按固定间隔生成,取相邻差的中位数即可还原真实 + 采样间隔,避免结束时间与抽取参数不一致。清单不足两帧时回退默认值。 + """ + diffs = [ + float(manifest[i + 1]["time"]) - float(manifest[i]["time"]) + for i in range(len(manifest) - 1) + if float(manifest[i + 1]["time"]) > float(manifest[i]["time"]) + ] + if not diffs: + return default + diffs.sort() + # 取 3 位小数:与 frame-extract 的 round(秒,3) 时间戳精度一致,避免浮点漂移。 + return round(diffs[len(diffs) // 2], 3) + + +def _assemble_srt( + kept: list[tuple[float, float, str]], + interval_seconds: float, +) -> list[str]: + """把 (起始帧时间, 最后可见帧时间, 文本) 序列组装为 SRT 行列表。 + + 每条字幕的结束时间 = 最后可见帧时间 + 采样间隔:字幕在最后一个被识别 + 到的帧之后的一个采样间隔内消失,与视频烧录时间对齐;两段字幕之间的 + 空白段(无字幕帧)不再被并入前一条字幕。 + """ + lines: list[str] = [] + for index, (start, last_seen, text) in enumerate(kept): + end = last_seen + interval_seconds + lines.extend( + [ + str(index + 1), + f"{format_timestamp(start)} --> {format_timestamp(end)}", + text, + "", + ] + ) + return lines + + +def invoke(request: InvokeRequest) -> InvokeResponse: + """逐帧 OCR 并汇总字幕,产物为 subtitle.srt。""" + manifest_uri = request.inputs.get("frames_manifest") + if not manifest_uri: + return InvokeResponse(status="failed", error="frames_manifest is required") + manifest_path = Path(manifest_uri) + if not manifest_path.is_file(): + return InvokeResponse(status="failed", error="frames manifest not found") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + # 结果长度上限:超过即视为模型异常(重复循环等),该帧直接报错跳过。 + max_result_chars = int(request.params.get("max_result_chars", 200)) + # 采样间隔优先从帧清单时间轴推导(与 frame-extract 实际抽取间隔一致), + # 参数仅作清单退化时的兜底。 + interval = _sampling_interval( + manifest, float(request.params.get("interval_seconds", 2.0)) + ) + # 透传给 vlm-ocr 的参数(仅传已提供的,避免覆盖其默认值)。 + vlm_params = { + key: request.params.get(key) + for key in ( + "model", "ollama_host", "prompt", "timeout_seconds", "keep_alive", + "temperature", "repeat_penalty", "num_predict", + ) + if request.params.get(key) is not None + } + + # 单帧 OCR:并行池的工作函数,返回该帧识别文本(失败/空/超长均返回空串)。 + def ocr_frame(payload) -> str: + index, item = payload + response = registry.invoke( + "vlm-ocr", + InvokeRequest( + run_id=request.run_id, + node_instance_id="", + inputs={"image_uri": str(item["image_uri"])}, + params=vlm_params, + output_dir=str(Path(request.output_dir) / "ocr_frames" / f"{index:04d}"), + ), + ) + if response.status != "completed": + # 单帧失败不中断整体,跳过该帧继续汇总。 + logger.warning("帧 %d OCR 失败,跳过: %s", index, response.error) + return "" + logger.info("帧 %d/%d OCR 完成: %r", index + 1, len(manifest), response.outputs.get("text")) + text = str(response.outputs.get("text", "")).strip() + if not text: + return "" + # 超长输出视为模型异常(重复循环等),直接报错并跳过该帧。 + if len(text) > max_result_chars: + logger.warning( + "帧 %d OCR 输出超长(%d > %d),跳过: %r", + index, len(text), max_result_chars, text[:60], + ) + return "" + return text + + # 进度日志:打印已识别帧数、总数与平均处理速度(帧/s)。 + def log_progress(done: int, total: int, rate: float) -> None: + logger.info("OCR 进度 %d/%d 帧 (%.1f 帧/s)", done, total, rate) + + # 自适应并发调用 vlm-ocr:10s 窗口内平均响应 < 0.3s 则加 1 线程(上限 + # pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1), + # 按实测负载弹性伸缩,避免盲目并发压垮本地 Ollama。 + pool = AdaptiveThreadPool( + worker=ocr_frame, + on_progress=log_progress, + min_workers=int(request.params.get("pool_min_workers", 1)), + max_workers=int(request.params.get("pool_max_workers", 16)), + window_seconds=float(request.params.get("pool_window_seconds", 10.0)), + fast_threshold=float(request.params.get("pool_fast_threshold", 0.3)), + slow_threshold=float(request.params.get("pool_slow_threshold", 1.0)), + ) + texts = pool.map(list(enumerate(manifest))) + + # kept 元素为 (起始帧时间, 最后可见帧时间, 文本);按帧顺序合并连续相同字幕。 + kept: list[tuple[float, float, str]] = [] + for index, text in enumerate(texts): + if not text: + continue + time = float(manifest[index]["time"]) + # 连续帧相同字幕合并为一条(字幕停留多帧属正常现象): + # 仅更新最后可见帧时间,起始时间保持首次出现。 + if kept and kept[-1][2] == text: + kept[-1] = (kept[-1][0], time, text) + continue + kept.append((time, time, text)) + + output_dir = Path(request.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "subtitle.srt" + output_path.write_text("\n".join(_assemble_srt(kept, interval)), encoding="utf-8") + logger.info("字幕汇总完成: %d 条", len(kept)) + return InvokeResponse( + status="completed", + outputs={"srt_uri": str(output_path), "count": len(kept)}, + ) diff --git a/nodes/vlm.py b/nodes/vlm.py new file mode 100644 index 0000000..f8cd75d --- /dev/null +++ b/nodes/vlm.py @@ -0,0 +1,193 @@ +"""VLM OCR 节点。 + +调用本地 Ollama 服务的多模态模型(默认 glm-ocr:latest)对图片做文字识别 +(OCR),用于从视频帧中提取字幕文字,构建"真实音频 + 正确字幕"的测试数据。 + +与 llm-translate 节点相互独立:本节点只做视觉 OCR,不做翻译,避免把两类 +职责混在一起。调用协议见 Ollama 官方文档:POST /api/chat。 + +请求约定(2026-08 调整,直接请求 API 版本): +- 使用流式传输(stream=True),逐行接收生成内容,命中终止序列立即停止接收, + 避免模型重复循环时无限拉取输出;响应体整体受 5 秒截止时间约束。 +- 采样 temperature 默认 0.3(可参数覆盖),并携带终止序列列表 + `["\n", "\n答", "答"]`(输出首个换行即停 + 阻止“答:”式重复循环); + 模型生成遇到任一标记即停止。 +- 每次调用整体超时 5 秒(timeout_seconds 参数 / VLM_TIMEOUT_SECONDS 环境变量), + 超过即终止,不再继续等待后续流式块。 +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import time +import urllib.error +import urllib.request +from pathlib import Path + +from wov_sdk.models import InvokeRequest, InvokeResponse + +# 终止序列列表:模型输出一旦出现任一标记(如换行 "\n"、"答")立即停止生成 +# (Ollama 的 stop 参数,同时作为流式读取时的本地截断标记)。 +# 语义:输出第一个换行即停(单行字幕),"答" 系列阻止"答:"式重复循环。 +STOP_SEQUENCE = ["\n","\n答","答"] + + +def _default_host() -> str: + """默认 Ollama 服务地址,可通过环境变量覆盖。""" + return os.getenv("OLLAMA_HOST", "http://192.168.123.70:11434") + + +def _clean_ocr_text(content: str) -> str: + """清洗模型输出:去掉 markdown 围栏与空行,只保留识别到的文字。 + + glm-ocr 的自定义解析器会在识别文本后追加大量 ``` 围栏,必须剔除, + 否则会污染字幕文本。 + """ + lines: list[str] = [] + for line in content.splitlines(): + stripped = line.strip() + # 整行只有反引号(可带语言标记,如 ```markdown)的围栏行丢弃。 + if re.fullmatch(r"`+[A-Za-z0-9]*", stripped): + continue + if stripped: + lines.append(stripped) + return "\n".join(lines) +# 标签提取正则:匹配 ...(DOTALL 让 . 也能匹配换行)。 +_GETTEXT_RE = re.compile(r"(.*?)", re.DOTALL) + + +def _extract_gettext(raw: str) -> str: + """从模型输出中提取 标签内的内容。 + + 提示词要求模型用该标签包裹识别结果;模型未按格式输出(找不到标签) + 时回退返回原始文本,保持旧行为。多个标签只取第一个(防重复循环)。 + """ + match = _GETTEXT_RE.search(raw) + if match is None: + return raw + return match.group(1) + + +def _truncate_at_stop(raw: str) -> str: + """在最先命中的终止序列处截断文本。 + + STOP_SEQUENCE 为终止序列列表(如 ["\n", "\n答", "答"]):取最早出现的位置 + 截断,返回截断后的文本;未命中任何序列时原样返回。 + """ + positions = [pos for seq in STOP_SEQUENCE if (pos := raw.find(seq)) != -1] + if not positions: + return raw + return raw[: min(positions)] + + +def _consume_stream(response, deadline: float) -> str: + """逐行读取流式响应,直到命中终止序列 / 流结束 / 超过截止时间。 + + response 为 urllib 打开的响应对象,readline() 返回 bytes(每行一个 + Ollama 流式 JSON 块)。返回拼接后的原始文本(未清洗、未截断终止序列)。 + """ + parts: list[str] = [] + while True: + # 每次调用整体 5 秒上限:超过立即终止,不再等待下一个流式块。 + if time.monotonic() >= deadline: + raise TimeoutError("timed out") + line = response.readline() + if not line: + # 流式输出正常结束(模型未发出终止序列,直接收完)。 + break + chunk = json.loads(line.decode("utf-8")) + message = chunk.get("message") + if message is None: + # done 行是流结束标记,可能不带 message;其余缺失视为格式错误。 + if chunk.get("done"): + break + raise KeyError("missing message in stream chunk") + parts.append(str(message.get("content", ""))) + # 命中任一终止序列即停止接收后续内容(模型应已结束生成)。 + if any(seq in "".join(parts) for seq in STOP_SEQUENCE): + break + if chunk.get("done"): + # 模型侧完成(未命中终止序列也自然结束)。 + break + return "".join(parts) +def invoke(request: InvokeRequest) -> InvokeResponse: + """识别 image_uri 指向的图片中的文字,产物为 ocr.txt。 + + 参数:model(默认 glm-ocr:latest)、ollama_host、prompt、 + timeout_seconds(默认 5,每次调用整体上限);均可通过环境变量 + VLM_MODEL / OLLAMA_HOST / VLM_TIMEOUT_SECONDS 覆盖。 + """ + image_uri = request.inputs.get("image_uri") + if not image_uri: + return InvokeResponse(status="failed", error="image_uri is required") + + image_path = Path(image_uri) + # 文件不存在时提前失败,避免无谓的网络请求。 + if not image_path.is_file(): + return InvokeResponse(status="failed", error="image file not found") + + host = str(request.params.get("ollama_host") or _default_host()) + model = str(request.params.get("model") or os.getenv("VLM_MODEL", "glm-ocr:latest")) + prompt = str( + request.params.get("prompt") + or os.getenv("VLM_PROMPT", "提取图像中的文字,不要描述图片中的内容") + ) + timeout = float( + request.params.get("timeout_seconds") + or os.getenv("VLM_TIMEOUT_SECONDS", "5") + ) + + # 图片按 base64 随请求体发送(Ollama 多模态标准格式)。 + image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii") + body = { + "model": model, + # 流式传输:逐行接收生成内容,命中终止序列或超时即停止。 + "stream": True, + # keep_alive 让模型在服务端常驻,避免逐帧调用反复加载模型。 + "keep_alive": str(request.params.get("keep_alive", "5m")), + # 采样选项:temperature 默认 0.3(可参数覆盖);glm-ocr 在大图上有已知 + # 重复循环 bug,repeat_penalty 惩罚重复 token、num_predict 限制输出上限。 + "options": { + "temperature": float(request.params.get("temperature", 0.3)), + "repeat_penalty": float(request.params.get("repeat_penalty", 1)), + "num_predict": int(request.params.get("num_predict", 256)), + }, + # /api/chat 的输入结构:识别指令放系统提示词,用户消息只携带图片 + # (content 为空、images 传 base64,与 glm-ocr 期望结构一致)。 + "messages": [ + {"role": "system", "content": prompt}, + {"role": "user", "content": "", "images": [image_b64]}, + ], + # 模型遇到任一终止序列即停止生成,遏制重复循环;同时作为流式读取截断点。 + "stop": STOP_SEQUENCE, + } + request_url = f"{host.rstrip('/')}/api/chat" + http_request = urllib.request.Request( + request_url, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + # timeout 同时作用于连接与每次 socket 读取;deadline 保证整体 5 秒上限。 + deadline = time.monotonic() + timeout + with urllib.request.urlopen(http_request, timeout=timeout) as response: + raw = _consume_stream(response, deadline) + # 终止序列可能随最后一个流式块一起返回,在最先命中的序列处截断再清洗。 + raw = _truncate_at_stop(raw) + # 提取提示词约定的 标签内容;模型未按格式输出时回退原始文本。 + text = _clean_ocr_text(_extract_gettext(raw)) + output_dir = Path(request.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "ocr.txt" + output_path.write_text(text + "\n", encoding="utf-8") + return InvokeResponse( + status="completed", + outputs={"text": text, "text_uri": str(output_path)}, + ) + except (urllib.error.URLError, KeyError, ValueError, OSError) as exc: + # 网络失败、响应格式异常、超时等统一转换为 failed 响应。 + return InvokeResponse(status="failed", error=str(exc)) diff --git a/nodes/whisper.py b/nodes/whisper.py new file mode 100755 index 0000000..fbf3cff --- /dev/null +++ b/nodes/whisper.py @@ -0,0 +1,274 @@ +"""faster-whisper ASR 节点。 + +单体版中作为进程内节点模块,由调度器直接调用。模型权重默认优先从本地 +目录加载,避免从远端下载,仅在本地找不到模型时才回退到远端 large-v3。 +CUDA 动态库通过 ctypes 在进程内预加载,替代分布式版的 LD_LIBRARY_PATH 注入。 +""" + +from __future__ import annotations + +import ctypes +import os +import subprocess +import sysconfig +import time +import wave +from pathlib import Path + +from nodes.ffmpeg import _ffmpeg_bin +from wov_app.logging import get_logger +from wov_sdk.models import InvokeRequest, InvokeResponse + +# 转写进度日志:输出到主进程控制台,长音频分块时可见每块进度。 +logger = get_logger("whisper") + + +def _is_windows() -> bool: + """判断当前是否为 Windows,供测试单独注入覆盖。""" + return os.name == "nt" + + +def _load_cuda_libraries() -> None: + """在进程内预加载 pip 安装的 NVIDIA 动态库。 + + 分布式版通过给节点子进程注入 LD_LIBRARY_PATH(Windows 为 PATH)解决; + 单体版没有进程边界,必须在导入 faster-whisper 前加载 nvidia 轮子自带 + 的 .so/.dll,否则 ctranslate2 初始化 CUDA 时找不到 libcublas.so.12。 + """ + # site-packages 目录,nvidia 各包的动态库位于其下。 + site_packages = Path(sysconfig.get_paths()["purelib"]) + for vendor in ("cublas", "cudnn", "cuda_nvrtc"): + # Windows 轮子把 dll 放在 bin/,Linux 放在 lib/。 + for subdir in ("bin", "lib"): + lib_dir = site_packages / "nvidia" / vendor / subdir + if not lib_dir.is_dir(): + continue + if _is_windows(): + # Windows 通过 DLL 搜索目录注册,等价于进程内 PATH 注入。 + os.add_dll_directory(str(lib_dir)) + else: + for so_file in sorted(lib_dir.glob("*.so*")): + try: + ctypes.CDLL(str(so_file)) + except OSError: + # 个别依赖缺失(如 libcudart)时跳过,交由 ctranslate2 报错。 + continue + +def _local_model_candidates() -> list[Path]: + """返回本地模型候选目录:单体根目录 model/ 优先,其次 nodes/ 同级 model/。 + + 单体根目录 model/ 对应仓库根下的 model/faster-whisper-large-v3, + nodes/ 同级 model/ 允许部署时把权重随代码目录一起携带。 + """ + monolith_root = Path(__file__).resolve().parent.parent + return [ + monolith_root / "model" / "faster-whisper-large-v3", + monolith_root / "nodes" / "model" / "faster-whisper-large-v3", + ] + + +def resolve_model_path( + params: dict, + env: dict | None = None, + candidates: list[Path] | None = None, +) -> str: + """按 参数 > 环境变量 > 本地候选目录 > 远端 large-v3 的顺序解析模型路径。 + + 本地优先是默认行为:只要候选目录存在且包含 model.bin 就使用本地权重, + 避免从 Hugging Face 下载;远端下载仅在全部本地候选缺失时作为兜底。 + 参数/环境变量传入的是裸模型名(不含路径分隔符)时,会先在本地模型 + 目录(model/)下按名解析,方便工作流直接引用下载好的模型。 + candidates 参数供测试注入临时目录,默认使用 _local_model_candidates()。 + """ + env = env if env is not None else os.environ + candidates = candidates if candidates is not None else _local_model_candidates() + explicit = params.get("model_path") or env.get("WHISPER_MODEL_PATH") + if explicit: + explicit_str = str(explicit) + # 裸模型名按 <模型目录>/<名称> 在本地解析,例如 + # "whisper-large-v3-translate-zh-v0.1-lt-ct2"。 + if "/" not in explicit_str and "\\" not in explicit_str: + named = candidates[0].parent / explicit_str + if (named / "model.bin").is_file(): + return str(named) + return explicit_str + for candidate in candidates: + # model.bin 是 CTranslate2 权重的必需文件,存在才认为模型完整。 + if candidate.is_dir() and (candidate / "model.bin").is_file(): + return str(candidate) + return "large-v3" + +def format_timestamp(seconds: float) -> str: + """把秒数格式化为 SRT 时间戳,例如 01:00:00,500。""" + # 先换算成毫秒再逐级拆分为时/分/秒/毫秒,避免浮点误差。 + total_ms = int(seconds * 1000) + hours, remainder = divmod(total_ms, 3600000) + minutes, remainder = divmod(remainder, 60000) + secs, millis = divmod(remainder, 1000) + return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + + +def _split_audio( + audio_path: Path, + output_dir: Path, + chunk_seconds: int, + ffmpeg_bin: str | None, +) -> list[Path]: + """用 ffmpeg 把音频切成 chunk_seconds 秒一块的 wav,返回块路径列表。 + + 分块是应用层工程策略(内存有界、失败粒度小),与模型 30s 窗口无关; + whisper 训练与推理都按 30s 窗口解码,任意块大小都适用。以下情况回退 + 为整段单次转写:chunk_seconds <= 0、找不到 ffmpeg、切块失败、音频本身 + 不足一块(ffmpeg 产出单块)。 + """ + if chunk_seconds <= 0 or not ffmpeg_bin: + return [audio_path] + chunk_dir = output_dir / "chunks" + chunk_dir.mkdir(parents=True, exist_ok=True) + pattern = str(chunk_dir / "chunk_%03d.wav") + # 音频已是 16kHz 单声道 WAV,流拷贝切块即可,无需重编码。 + result = subprocess.run( + [ + ffmpeg_bin, + "-y", + "-i", + str(audio_path), + "-f", + "segment", + "-segment_time", + str(chunk_seconds), + "-c", + "copy", + pattern, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + # 切块失败(输入损坏等)回退整段,不让转写流程中断。 + return [audio_path] + chunks = sorted(chunk_dir.glob("chunk_*.wav")) + return chunks or [audio_path] + + +def _wav_duration_seconds(path: Path, fallback: float) -> float: + """从 WAV 头精确读取时长;文件非法/非 WAV 时用 fallback 兜底。 + + 分块偏移必须用每块的实际时长累积,而不是 块序号×块长 的假设值—— + ffmpeg 切出的块实际时长并不精确等于块长(如 60.05s),假设值会随 + 块数累积漂移,造成字幕时间轴逐渐错位。 + """ + try: + with wave.open(str(path), "rb") as wav: + rate = wav.getframerate() + return wav.getnframes() / rate if rate else fallback + except (wave.Error, EOFError, OSError): + # 文件损坏/非 WAV(如 mp4 直传)时用 fallback 兜底。 + return fallback + + +def _append_srt_lines(lines: list[str], segments, offset: float, start_index: int) -> int: + """把一段转写结果按 SRT 格式追加到 lines,时间加上 offset 偏移。 + + 分块合并时每块 offset 为前面所有块的实际时长累积;单次调用 offset=0。 + 每写出一条字幕就打印其编号与完整视频角度的时间范围,便于对照对齐。 + 返回本段新增的条数,用于全局序号递增。 + """ + count = 0 + for segment in segments: + # 完整视频角度的时间 = 模型预测时间 + 累积偏移。 + start_time = segment.start + offset + end_time = segment.end + offset + lines.extend( + [ + str(start_index + count), + f"{format_timestamp(start_time)} --> {format_timestamp(end_time)}", + segment.text.strip(), + "", + ] + ) + logger.info( + "分段 #%d: %s --> %s", + start_index + count, + format_timestamp(start_time), + format_timestamp(end_time), + ) + count += 1 + return count + +def invoke(request: InvokeRequest) -> InvokeResponse: + """转写音频并生成 SRT 字幕,产物为 transcript.srt。""" + audio_uri = request.inputs.get("audio_uri") + if not audio_uri: + return InvokeResponse(status="failed", error="audio_uri is required") + + # 文件不存在时提前失败,避免进入耗时的模型加载流程。 + audio_path = Path(audio_uri) + if not audio_path.is_file(): + return InvokeResponse(status="failed", error="audio file not found") + + try: + # 延迟导入 faster-whisper,保证节点注册与调度等轻量路径不依赖重型依赖; + # 导入前先预加载 NVIDIA 动态库,否则 ctranslate2 找不到 libcublas。 + _load_cuda_libraries() + from faster_whisper import WhisperModel + # 模型路径默认本地优先:参数 > 环境变量 > 工作区本地目录 > 远端兜底。 + model_path = resolve_model_path(request.params) + device = str(request.params.get("device") or os.getenv("WHISPER_DEVICE", "auto")) + # auto 让 faster-whisper 根据硬件自动选择 float16/int8 等计算类型。 + compute_type = str(request.params.get("compute_type") or "auto") + model = WhisperModel( + model_path, + device=device, + compute_type=compute_type, + ) + # language 默认日语;vad_filter 默认开启(用户 2026-08 决定):过滤静音 + # 段以提速并减少无语音处幻觉;长静音时 VAD 压缩时间轴可能轻微错位, + # 如需极致对齐可在工作流参数中显式关闭。 + # task 默认 transcribe,中文直出模型可传 translate 直接翻译为目标语言。 + # condition_on_previous_text 默认 False:长音频下开启会导致重复/漂移, + # 关闭后每个 30s 窗口独立解码,是 faster-whisper 官方建议的长音频方案。 + output_dir = Path(request.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + # 分块转写:默认每 1 分钟一块(chunk_seconds=60),切块失败自动回退整段。 + chunk_seconds = int(request.params.get("chunk_seconds", 60)) + chunks = _split_audio(audio_path, output_dir, chunk_seconds, _ffmpeg_bin()) + # 逐块转写并合并:offset 用每块实际时长累积(WAV 头精确),SRT 序号连续。 + logger.info("转写开始: %d 个分块", len(chunks)) + lines: list[str] = [] + offset = 0.0 + # SRT 序号从 1 开始,跨块连续递增。 + srt_number = 1 + transcribe_started = time.monotonic() + for chunk_index, chunk in enumerate(chunks, start=1): + chunk_started = time.monotonic() + segments, _info = model.transcribe( + str(chunk), + language=str(request.params.get("language", "ja")), + task=str(request.params.get("task", "transcribe")), + beam_size=int(request.params.get("beam_size", 1)), + vad_filter=bool(request.params.get("vad_filter", True)), + condition_on_previous_text=bool( + request.params.get("condition_on_previous_text", False) + ), + ) + # 进度日志:块序号/总数、单块耗时、实时倍率(块音频时长/墙钟耗时) + # 与转写累计耗时,直观反映数据处理速度。 + chunk_elapsed = time.monotonic() - chunk_started + srt_number += _append_srt_lines(lines, segments, offset, srt_number) + # 偏移按本块实际时长推进,避免假设块长导致的累积漂移。 + offset += _wav_duration_seconds(chunk, chunk_seconds) + logger.info( + "分块 %d/%d 完成 offset=%.2fs 耗时 %.1fs (%.2fx 实时, 累计 %.1fs)", + chunk_index, len(chunks), offset, chunk_elapsed, + chunk_seconds / chunk_elapsed if chunk_elapsed > 0 else 0.0, + time.monotonic() - transcribe_started, + ) + output_path = output_dir / "transcript.srt" + output_path.write_text("\n".join(lines), encoding="utf-8") + return InvokeResponse(status="completed", outputs={"srt_uri": str(output_path)}) + except Exception as exc: # noqa: BLE001 + # 模型加载或转写异常统一转换为 failed 响应,不让调度线程崩溃。 + return InvokeResponse(status="failed", error=str(exc)) + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fc4cff3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "vrsub" +version = "0.1.0" +description = "VRSub:为视频生成 VR 双眼字幕的单体应用(API、调度器与全部节点在单进程内运行)" +requires-python = ">=3.11" +dependencies = [ + # Web 框架:FastAPI 负责 API 与静态前端挂载。 + "fastapi", + "uvicorn", + "python-multipart", + # 转写节点:faster-whisper 及其 CUDA 动态库(同进程直接加载,无需路径注入)。 + "faster-whisper>=1.2.1", + "nvidia-cublas-cu12>=12.9.2.10", + "nvidia-cudnn-cu12>=9.24.0.43", + # 提音节点:imageio-ffmpeg 提供内置 ffmpeg 兜底。 + "imageio-ffmpeg>=0.6", + "python-dotenv>=1.2.2", +] + +[dependency-groups] +dev = [ + "pytest", + "pytest-cov", + "httpx", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +# wov_sdk / wov_app 位于 src/,nodes 位于仓库根,一并作为可安装包。 +[tool.setuptools.packages.find] +where = ["src", "."] +include = ["wov_sdk*", "wov_app*", "nodes*"] + +# pytest 配置:扫描 tests 目录并强制 100% 行覆盖率;同时把仓库根加入 +# sys.path,保证 nodes 包在未重新安装时也能被导入。 +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "--cov=src --cov=nodes --cov-fail-under=100 -p no:cacheprovider" diff --git a/src/wov_app/__init__.py b/src/wov_app/__init__.py new file mode 100644 index 0000000..8d23025 --- /dev/null +++ b/src/wov_app/__init__.py @@ -0,0 +1,5 @@ +"""WOV 单体应用包。 + +包含 FastAPI 应用入口、SQLite 数据访问、进程内节点注册表、工作流调度器 +以及管理端/用户端路由。所有节点在同一进程内直接调用,无子进程边界。 +""" diff --git a/src/wov_app/config.py b/src/wov_app/config.py new file mode 100644 index 0000000..80cda51 --- /dev/null +++ b/src/wov_app/config.py @@ -0,0 +1,28 @@ +"""应用配置中心。 + +集中读取环境变量并推导路径常量,避免业务代码散落魔法值。路径统一使用 +pathlib,Windows 与 Linux 开发环境均可用。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# WOV 单体根目录:本文件位于 src/wov_app/config.py,向上三级即仓库根。 +WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent + +# 数据目录、SQLite 文件与产物存储目录均可通过环境变量覆盖,便于测试隔离。 +DATA_DIR = Path(os.getenv("WOV_DATA_DIR", str(WORKSPACE_ROOT / "data"))) +DB_PATH = Path(os.getenv("WOV_DB_PATH", str(DATA_DIR / "wov.db"))) +STORAGE_DIR = Path(os.getenv("WOV_STORAGE_DIR", str(DATA_DIR / "storage"))) + +# 调度器轮询排队任务的间隔(秒)。 +SCHEDULER_INTERVAL_SECONDS = float(os.getenv("WOV_SCHEDULER_INTERVAL_SECONDS", "1.0")) + +# 孤儿数据清理器配置:定时扫描并清理无对应文件/记录的死数据。 +CLEANUP_ENABLED = os.getenv("WOV_CLEANUP_ENABLED", "1") == "1" +# 清理扫描周期(秒),默认每小时一次。 +CLEANUP_INTERVAL_SECONDS = float(os.getenv("WOV_CLEANUP_INTERVAL_SECONDS", "3600")) +# 宽限期(秒):任务最后更新距今超过该时长且满足孤儿条件才清理。 +CLEANUP_GRACE_SECONDS = float(os.getenv("WOV_CLEANUP_GRACE_SECONDS", "3600")) diff --git a/src/wov_app/db.py b/src/wov_app/db.py new file mode 100644 index 0000000..8285fa6 --- /dev/null +++ b/src/wov_app/db.py @@ -0,0 +1,398 @@ +"""SQLite 数据访问层。 + +所有持久化逻辑集中在本模块,业务代码只依赖 Database 提供的方法。后续切换 +PostgreSQL 时只需替换本层实现,不修改调度器与路由的业务逻辑。 +""" + +from __future__ import annotations + +import json +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + + +class Database: + """SQLite 数据库封装:负责建表以及工作流/任务/产物的 CRUD。""" + + def __init__(self, path: Path) -> None: + """打开数据库并确保父目录存在、表结构已初始化。""" + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + self._init_schema() + + @contextmanager + def _connect(self) -> Iterator[sqlite3.Connection]: + """提供带事务提交的数据库连接上下文。""" + conn = sqlite3.connect(self.path) + # 按列名读取结果,返回 dict 更直观。 + conn.row_factory = sqlite3.Row + # 开启外键约束,保证子表记录引用有效。 + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + conn.commit() + finally: + conn.close() + + def _init_schema(self) -> None: + """创建全部业务表;已存在的表保持不变。""" + with self._connect() as conn: + conn.executescript( + """ + -- 工作流表:只保存概要信息,完整定义存版本表。 + CREATE TABLE IF NOT EXISTS workflows ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + published INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + -- 工作流版本表:每个版本保存一份 DAG 定义 JSON。 + CREATE TABLE IF NOT EXISTS workflow_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id TEXT NOT NULL, + version INTEGER NOT NULL, + definition_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(workflow_id, version), + FOREIGN KEY(workflow_id) REFERENCES workflows(id) + ); + + -- 工作流运行表:记录任务从排队到完成/失败的状态机。 + CREATE TABLE IF NOT EXISTS workflow_runs ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + workflow_version INTEGER NOT NULL, + status TEXT NOT NULL, + current_node_id TEXT, + progress REAL NOT NULL DEFAULT 0, + error TEXT, + input_uri TEXT, + param_overrides TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(workflow_id) REFERENCES workflows(id) + ); + + -- 产物表:记录每个任务各节点的输出 URI,按名称唯一。 + CREATE TABLE IF NOT EXISTS artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + name TEXT NOT NULL, + uri TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(run_id, name), + FOREIGN KEY(run_id) REFERENCES workflow_runs(id) + ); + """ + ) + + # 旧库迁移:workflow_runs 补充 param_overrides 列(前端框选覆盖)。 + columns = [ + row["name"] + for row in conn.execute("PRAGMA table_info(workflow_runs)").fetchall() + ] + if "param_overrides" not in columns: + conn.execute("ALTER TABLE workflow_runs ADD COLUMN param_overrides TEXT") + + def upsert_workflow(self, workflow: dict[str, Any]) -> None: + """插入或更新工作流概要信息。""" + with self._connect() as conn: + conn.execute( + """ + INSERT INTO workflows (id, name, description, published, latest_version) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + published = excluded.published, + latest_version = excluded.latest_version + """, + ( + workflow["id"], + workflow["name"], + workflow.get("description", ""), + int(workflow.get("published", 0)), + int(workflow.get("latest_version", 0)), + ), + ) + + def get_workflow(self, workflow_id: str) -> dict[str, Any] | None: + """按 ID 读取工作流概要。""" + with self._connect() as conn: + row = conn.execute("SELECT * FROM workflows WHERE id = ?", (workflow_id,)).fetchone() + return dict(row) if row else None + + def list_workflows(self) -> list[dict[str, Any]]: + """按创建时间倒序返回全部工作流。""" + with self._connect() as conn: + rows = conn.execute("SELECT * FROM workflows ORDER BY created_at DESC").fetchall() + return [dict(row) for row in rows] + + def delete_workflow(self, workflow_id: str) -> None: + """级联删除工作流相关的产物、任务、版本和概要记录。""" + with self._connect() as conn: + # 外键没有级联删除配置,手动按依赖顺序清理。 + conn.execute("DELETE FROM artifacts WHERE run_id IN (SELECT id FROM workflow_runs WHERE workflow_id = ?)", (workflow_id,)) + conn.execute("DELETE FROM workflow_runs WHERE workflow_id = ?", (workflow_id,)) + conn.execute("DELETE FROM workflow_versions WHERE workflow_id = ?", (workflow_id,)) + conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) + + def create_workflow_version(self, workflow_id: str, version: int, definition: dict[str, Any]) -> None: + """为工作流新增一个版本,definition 以 JSON 保存。""" + with self._connect() as conn: + conn.execute( + """ + INSERT INTO workflow_versions (workflow_id, version, definition_json) + VALUES (?, ?, ?) + """, + (workflow_id, version, json.dumps(definition, ensure_ascii=False)), + ) + + def get_latest_workflow_version(self, workflow_id: str) -> dict[str, Any] | None: + """返回工作流最新版本,并把 definition_json 反序列化为 definition。""" + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM workflow_versions + WHERE workflow_id = ? + ORDER BY version DESC + LIMIT 1 + """, + (workflow_id,), + ).fetchone() + if row is None: + return None + result = dict(row) + # 对外统一暴露 definition 字典,隐藏 JSON 存储细节。 + result["definition"] = json.loads(result.pop("definition_json")) + return result + + def get_workflow_version(self, workflow_id: str, version: int) -> dict[str, Any] | None: + """按版本号读取指定工作流版本。""" + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM workflow_versions + WHERE workflow_id = ? AND version = ? + """, + (workflow_id, version), + ).fetchone() + if row is None: + return None + result = dict(row) + result["definition"] = json.loads(result.pop("definition_json")) + return result + + def list_workflow_versions(self, workflow_id: str) -> list[dict[str, Any]]: + """按版本倒序返回工作流全部版本。""" + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM workflow_versions + WHERE workflow_id = ? + ORDER BY version DESC + """, + (workflow_id,), + ).fetchall() + versions = [] + for row in rows: + item = dict(row) + item["definition"] = json.loads(item.pop("definition_json")) + versions.append(item) + return versions + + def create_run(self, run: dict[str, Any]) -> None: + """创建一条排队中的工作流运行记录。""" + with self._connect() as conn: + conn.execute( + """ + INSERT INTO workflow_runs ( + id, workflow_id, workflow_version, status, current_node_id, + progress, error, input_uri, param_overrides, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + run["id"], + run["workflow_id"], + run["workflow_version"], + run["status"], + run.get("current_node_id"), + float(run.get("progress", 0)), + run.get("error"), + run.get("input_uri"), + json.dumps(run["param_overrides"], ensure_ascii=False) + if run.get("param_overrides") + else None, + run["created_at"], + run["updated_at"], + ), + ) + + def get_run(self, run_id: str) -> dict[str, Any] | None: + """按 ID 读取任务运行记录。""" + with self._connect() as conn: + row = conn.execute("SELECT * FROM workflow_runs WHERE id = ?", (run_id,)).fetchone() + return self._parse_overrides(row) if row else None + + + @staticmethod + def _parse_overrides(row) -> dict: + """把查询行中的 param_overrides JSON 字符串解析为字典。""" + result = dict(row) + raw = result.get("param_overrides") + result["param_overrides"] = json.loads(raw) if raw else None + return result + + def list_runs(self, limit: int = 20) -> list[dict[str, Any]]: + """按创建时间倒序返回最近的运行记录。""" + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [self._parse_overrides(row) for row in rows] + + def list_run_ids(self) -> list[str]: + """返回全部任务 ID,供孤儿数据清理对照磁盘目录使用。""" + with self._connect() as conn: + rows = conn.execute("SELECT id FROM workflow_runs").fetchall() + return [row["id"] for row in rows] + + def update_run(self, run_id: str, **fields: Any) -> None: + """更新运行状态字段,同时刷新 updated_at;未知字段会被忽略。""" + # 只允许更新状态机相关字段,防止任意列被改写。 + allowed = { + "status", + "current_node_id", + "progress", + "error", + } + updates = {key: value for key, value in fields.items() if key in allowed} + if not updates: + return + updates["updated_at"] = fields.get("updated_at") + # 动态拼接 SET 子句,键来自白名单,不存在 SQL 注入风险。 + assignments = ", ".join(f"{key} = ?" for key in updates) + values = list(updates.values()) + [run_id] + with self._connect() as conn: + conn.execute(f"UPDATE workflow_runs SET {assignments} WHERE id = ?", values) + + def reset_run(self, run_id: str, updated_at: str) -> None: + """把失败任务重置为 QUEUED,并清空进度与旧产物,供重试使用。""" + with self._connect() as conn: + # 清空错误和进度,恢复到首次排队时的状态。 + conn.execute( + """ + UPDATE workflow_runs + SET status = 'QUEUED', current_node_id = NULL, progress = 0, + error = NULL, updated_at = ? + WHERE id = ? + """, + (updated_at, run_id), + ) + # 删除旧产物,避免重试后残留过期下载链接。 + conn.execute("DELETE FROM artifacts WHERE run_id = ?", (run_id,)) + + def next_queued_run(self) -> dict[str, Any] | None: + """按创建时间返回最早一条可执行任务(排队或已暂停待续跑)。""" + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM workflow_runs + WHERE status IN ('QUEUED', 'PAUSED') + ORDER BY created_at ASC + LIMIT 1 + """ + ).fetchone() + return self._parse_overrides(row) if row else None + + def pause_run(self, run_id: str, updated_at: str) -> None: + """暂停任务:置为 PAUSED;调度器会在节点边界检查并停止推进。""" + with self._connect() as conn: + conn.execute( + "UPDATE workflow_runs SET status = 'PAUSED', updated_at = ? WHERE id = ?", + (updated_at, run_id), + ) + + def resume_run(self, run_id: str, updated_at: str) -> None: + """继续任务:PAUSED 恢复为 QUEUED,等待调度器从断点续跑。""" + with self._connect() as conn: + conn.execute( + "UPDATE workflow_runs SET status = 'QUEUED', updated_at = ? WHERE id = ?", + (updated_at, run_id), + ) + + def restore_run_outputs(self, run_id: str) -> dict[str, dict[str, str]]: + """从已登记的产物重建各节点输出,供暂停后断点续跑使用。 + + 返回 {节点ID: {输出名: URI}};已完成节点的产物可直接作为后续节点的输入。 + """ + outputs: dict[str, dict[str, str]] = {} + for artifact in self.list_artifacts(run_id): + name = artifact["name"] + # 产物名形如 "节点ID.输出名"(如 a.data_uri),还原为 {输出名: URI}。 + prefix = artifact["node_id"] + "." + if name.startswith(prefix): + name = name[len(prefix):] + outputs.setdefault(artifact["node_id"], {})[name] = artifact["uri"] + return outputs + def create_artifact(self, artifact: dict[str, Any]) -> None: + """记录任务产物;同 run 与 name 冲突时覆盖。""" + with self._connect() as conn: + conn.execute( + """ + INSERT OR REPLACE INTO artifacts ( + run_id, node_id, name, uri, mime_type, size + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + artifact["run_id"], + artifact["node_id"], + artifact["name"], + artifact["uri"], + artifact.get("mime_type", ""), + int(artifact.get("size", 0)), + ), + ) + + def list_artifacts(self, run_id: str) -> list[dict[str, Any]]: + """按创建时间返回任务的全部产物。""" + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at", + (run_id,), + ).fetchall() + return [dict(row) for row in rows] + + def get_artifact(self, run_id: str, name: str) -> dict[str, Any] | None: + """按任务与产物名读取单个产物记录。""" + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM artifacts WHERE run_id = ? AND name = ?", + (run_id, name), + ).fetchone() + return dict(row) if row else None + + def delete_run_artifacts(self, run_id: str) -> None: + """删除任务的全部产物记录。""" + with self._connect() as conn: + conn.execute("DELETE FROM artifacts WHERE run_id = ?", (run_id,)) + + def delete_run(self, run_id: str) -> None: + """删除任务记录本身及其产物记录。 + + 产物表外键引用任务表,必须先删产物再删任务,否则违反外键约束。 + """ + with self._connect() as conn: + conn.execute("DELETE FROM artifacts WHERE run_id = ?", (run_id,)) + conn.execute("DELETE FROM workflow_runs WHERE id = ?", (run_id,)) diff --git a/src/wov_app/logging.py b/src/wov_app/logging.py new file mode 100644 index 0000000..3f72d74 --- /dev/null +++ b/src/wov_app/logging.py @@ -0,0 +1,31 @@ +"""轻量日志配置。 + +单体版所有节点在 API 主进程内运行,这里把节点运行日志直接输出到主进程 +控制台(uvicorn 的 stderr),便于观察各节点的执行过程与耗时。 +""" + +from __future__ import annotations + +import logging + +# 应用日志统一前缀,便于与其他库日志区分。 +_APP_LOGGER_NAME = "vrsub" + + +def _ensure_console_handler(logger: logging.Logger) -> None: + """为日志器附加控制台输出;已配置过则跳过,避免重复打印。""" + if any(isinstance(handler, logging.StreamHandler) for handler in logger.handlers): + return + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + # 不向 uvicorn 根日志传播,防止消息重复输出。 + logger.propagate = False + + +def get_logger(name: str) -> logging.Logger: + """获取并确保输出到主进程控制台的日志器。""" + logger = logging.getLogger(f"{_APP_LOGGER_NAME}.{name}") + _ensure_console_handler(logger) + return logger diff --git a/src/wov_app/main.py b/src/wov_app/main.py new file mode 100644 index 0000000..1887397 --- /dev/null +++ b/src/wov_app/main.py @@ -0,0 +1,82 @@ +"""FastAPI 应用入口。 + +负责组装数据库、进程内节点注册表、调度器与静态前端,并在应用生命周期内 +管理后台线程的启动与清理。单体版所有节点在同一进程内直接调用。 +""" + +from __future__ import annotations + +# 先加载 .env(含 LLM API Key 等本地配置),再导入读取环境变量的 config。 +from dotenv import load_dotenv + +load_dotenv() + +import os # noqa: E402 +from contextlib import asynccontextmanager # noqa: E402 +from pathlib import Path # noqa: E402 + +from fastapi import FastAPI # noqa: E402 +from fastapi.middleware.cors import CORSMiddleware # noqa: E402 +from fastapi.staticfiles import StaticFiles # noqa: E402 + +from wov_app import registry +from wov_app.config import DB_PATH, STORAGE_DIR, WORKSPACE_ROOT +from wov_app.db import Database +from wov_app.maintenance import OrphanCleaner +from wov_app.routers import apps, workflows +from wov_app.scheduler import WorkflowScheduler +from wov_app.seed import seed_default_workflows + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期:启动时初始化数据、注册节点和后台服务,退出时回收资源。""" + # 确保数据与存储目录存在,避免首次启动写文件失败。 + db = Database(DB_PATH) + STORAGE_DIR.mkdir(parents=True, exist_ok=True) + # 静态注册全部内置节点到进程内注册表(内存态,不落库)。 + registry.register_all() + # 默认创建演示工作流,可关闭便于测试。 + if os.getenv("WOV_AUTO_SEED", "1") == "1": + seed_default_workflows(db) + + scheduler = WorkflowScheduler(db, STORAGE_DIR) + # 调度器默认开启,处理排队中的任务;测试可关闭后手动执行。 + if os.getenv("WOV_SCHEDULER_ENABLED", "1") == "1": + scheduler.start() + cleaner = OrphanCleaner(db, STORAGE_DIR) + # 孤儿数据清理默认开启,定时清除死数据;测试可关闭。 + if os.getenv("WOV_CLEANUP_ENABLED", "1") == "1": + cleaner.start() + # 共享对象挂到 app.state,路由通过 Depends 延迟获取。 + app.state.db = db + app.state.scheduler = scheduler + app.state.cleaner = cleaner + yield + # 退出时先停调度器与清理器,避免残留后台线程。 + cleaner.stop() + scheduler.stop() + + +app = FastAPI(title="VRSub API(单体版)", version="0.1.0", lifespan=lifespan) + +# MVP 阶段不做鉴权,允许跨域便于本地调试与静态页面访问。 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(workflows.router) +app.include_router(apps.router) + + +@app.get("/health") +def health() -> dict: + """进程存活探针,供部署环境与前端检测后端可用性。""" + return {"status": "ok", "service": "wov-api", "mode": "monolith"} + + +# 静态前端目录位于仓库根下的 web,由 FastAPI 直接挂载。 +FRONTEND_DIR = WORKSPACE_ROOT / "web" +app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") diff --git a/src/wov_app/maintenance.py b/src/wov_app/maintenance.py new file mode 100644 index 0000000..d4cc00c --- /dev/null +++ b/src/wov_app/maintenance.py @@ -0,0 +1,129 @@ +"""孤儿数据清理器。 + +定时扫描存储目录与数据库,清理不再有意义的死数据: + +1. 磁盘上存在但没有对应任务记录的上传/步骤目录(删除任务中断等残留)。 +2. 状态为 COMPLETED 但产物文件已全部丢失、且超过宽限期的任务记录 + (这类任务在任务页会显示"完成"但下载全部 404,属于孤儿数据)。 + +出于安全考虑,以下数据**不会**被自动清理: + +- FAILED 任务(用户可能重试,且失败任务本就可能没有文件)。 +- 状态非终态(QUEUED/RUNNING)的任务。 +- 最近宽限期内的任务,避免误删刚完成的运行。 + +手动删除任务仍走删除接口,本模块只做保守的孤儿兜底。 +""" + +from __future__ import annotations + +import shutil +import threading +import time +from datetime import datetime, timezone +from pathlib import Path + +from wov_app.config import CLEANUP_GRACE_SECONDS, CLEANUP_INTERVAL_SECONDS +from wov_app.db import Database + + +class OrphanCleaner: + """后台孤儿清理器:周期扫描并清理孤儿数据,只保留明确的死数据。""" + + def __init__( + self, + db: Database, + storage_dir: Path, + interval_seconds: float | None = None, + grace_seconds: float | None = None, + ) -> None: + """保存依赖并初始化轮询线程控制字段。""" + self.db = db + self.storage_dir = storage_dir + self.interval_seconds = interval_seconds or CLEANUP_INTERVAL_SECONDS + self.grace_seconds = grace_seconds or CLEANUP_GRACE_SECONDS + self._thread: threading.Thread | None = None + self._stopping = False + + def start(self) -> None: + """启动清理线程;重复调用无副作用。""" + if self._thread is not None: + return + self._stopping = False + self._thread = threading.Thread( + target=self._loop, + name="wov-orphan-cleaner", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + """请求停止并等待清理线程退出。""" + self._stopping = True + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None + + def _loop(self) -> None: + """周期循环:每隔一个间隔执行一次清理。""" + while not self._stopping: + time.sleep(self.interval_seconds) + self.clean_once() + + def clean_once(self) -> int: + """执行一次孤儿清理,返回清理的数据条目数。""" + run_ids = set(self.db.list_run_ids()) + removed = 0 + # 1) 无对应任务记录的上传/步骤目录视为残留,直接删除。 + removed += self._clean_dangling(self.storage_dir / "uploads", run_ids) + removed += self._clean_dangling(self.storage_dir / "runs", run_ids) + # 2) COMPLETED 且产物文件全失、超过宽限期的任务记录删除。 + for run_id in run_ids: + run = self.db.get_run(run_id) + if run is None: + continue + if run["status"] != "COMPLETED": + continue + if not self._expired(run.get("updated_at")): + continue + if self._has_files(self.storage_dir / "runs" / run_id): + continue + removed += self._remove_run(run_id, run) + return removed + + def _clean_dangling(self, root: Path, run_ids: set[str]) -> int: + """删除 root 下没有对应任务记录的残留子目录,返回删除数。""" + if not root.is_dir(): + return 0 + removed = 0 + for child in root.iterdir(): + if child.is_dir() and child.name not in run_ids: + shutil.rmtree(child, ignore_errors=True) + removed += 1 + return removed + + def _expired(self, updated_at: str | None) -> bool: + """判断任务最后更新时间是否已超过宽限期;无法解析时保守视为未过期。""" + if not updated_at: + return False + try: + updated = datetime.fromisoformat(updated_at) + return (datetime.now(timezone.utc) - updated).total_seconds() > self.grace_seconds + except ValueError: + # 时间格式损坏时保守保留,避免误删。 + return False + + def _has_files(self, run_dir: Path) -> bool: + """判断任务目录下是否仍存在产物文件。""" + if not run_dir.is_dir(): + return False + return any(path.is_file() for path in run_dir.rglob("*")) + + def _remove_run(self, run_id: str, run: dict) -> int: + """删除孤儿任务:数据库记录(含产物)、上传目录与步骤目录。""" + self.db.delete_run(run_id) + input_uri = run.get("input_uri") + if input_uri: + shutil.rmtree(Path(input_uri).parent, ignore_errors=True) + shutil.rmtree(self.storage_dir / "runs" / run_id, ignore_errors=True) + return 1 diff --git a/src/wov_app/registry.py b/src/wov_app/registry.py new file mode 100644 index 0000000..811147a --- /dev/null +++ b/src/wov_app/registry.py @@ -0,0 +1,110 @@ +"""进程内节点注册表。 + +单体版不再启动子进程:节点清单与 invoke 处理器在启动时静态注册到本模块, +调度器通过 invoke(node_id, request) 在同一个进程内直接调用处理器。 +协议数据模型(NodeManifest / InvokeRequest / InvokeResponse)保持不变, +为将来回退分布式保留兼容桥梁。 +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from wov_app.logging import get_logger +from wov_sdk.models import InvokeRequest, InvokeResponse, NodeManifest + +# 节点运行日志:输出到主进程控制台。 +logger = get_logger("node") + +# 节点调用处理器签名:接收调用请求,返回调用结果。 +NodeHandler = Callable[[InvokeRequest], InvokeResponse] + + +@dataclass +class NodeEntry: + """注册表条目:节点清单与其进程内处理器。""" + + manifest: NodeManifest + handler: NodeHandler + + +# 进程内注册表:key 为节点 ID(即工作流中的 node_type),value 为注册条目。 +_registry: dict[str, NodeEntry] = {} + + +def _load_manifest(name: str) -> NodeManifest: + """从 manifests/ 目录加载节点清单文件并校验。""" + path = Path(__file__).resolve().parent.parent.parent / "manifests" / f"{name}.json" + return NodeManifest.load(str(path)) + + +def register(manifest: NodeManifest, handler: NodeHandler) -> None: + """注册单个节点;manifest 校验失败时抛出 ValueError。""" + manifest.validate() + _registry[manifest.id] = NodeEntry(manifest=manifest, handler=handler) + + +def register_all() -> None: + """注册全部内置节点,启动时调用一次;重复调用按 ID 覆盖,幂等。""" + from nodes import ass, echo, ffmpeg, frame_extract, llm, llm_filter, subtitle_ocr, vlm, whisper + register(_load_manifest("echo"), echo.invoke) + register(_load_manifest("ffmpeg"), ffmpeg.invoke) + register(_load_manifest("whisper"), whisper.invoke) + register(_load_manifest("llm"), llm.invoke) + register(_load_manifest("vlm"), vlm.invoke) + register(_load_manifest("frame-extract"), frame_extract.invoke) + register(_load_manifest("subtitle-ocr"), subtitle_ocr.invoke) + register(_load_manifest("llm-filter"), llm_filter.invoke) + register(_load_manifest("ass"), ass.invoke) + + +def list_nodes() -> list[NodeManifest]: + """按节点 ID 顺序返回全部已注册节点清单。""" + return [entry.manifest for _, entry in sorted(_registry.items())] + + +def get_node(node_id: str) -> NodeManifest | None: + """按节点 ID 返回清单;未注册时返回 None。""" + entry = _registry.get(node_id) + return entry.manifest if entry else None + + +def invoke(node_id: str, request: InvokeRequest) -> InvokeResponse: + """调用指定节点的进程内处理器;节点未注册时抛出 ValueError。 + + 统一在这里记录节点的开始/结束/耗时/产物日志,所有节点自动获得 + 主进程可见的运行日志,无需在各节点实现内重复埋点。 + """ + entry = _registry.get(node_id) + if entry is None: + raise ValueError(f"node not registered: {node_id}") + logger.info( + "节点 %s 开始 run=%s inputs=%s params=%s", + node_id, + request.run_id, + request.inputs, + request.params, + ) + start = time.perf_counter() + response = entry.handler(request) + elapsed = time.perf_counter() - start + if response.status == "completed": + logger.info( + "节点 %s 完成 run=%s 耗时=%.2fs outputs=%s", + node_id, + request.run_id, + elapsed, + response.outputs, + ) + else: + logger.warning( + "节点 %s 失败 run=%s 耗时=%.2fs error=%s", + node_id, + request.run_id, + elapsed, + response.error, + ) + return response diff --git a/src/wov_app/routers/__init__.py b/src/wov_app/routers/__init__.py new file mode 100644 index 0000000..9b46c03 --- /dev/null +++ b/src/wov_app/routers/__init__.py @@ -0,0 +1,5 @@ +"""WOV 单体 API 路由包。 + +按职责拆分为用户应用与工作流管理两组路由,统一由 wov_app.main 挂载。 +节点注册/实例管理路由已随单体化移除。 +""" diff --git a/src/wov_app/routers/apps.py b/src/wov_app/routers/apps.py new file mode 100755 index 0000000..3b2fb93 --- /dev/null +++ b/src/wov_app/routers/apps.py @@ -0,0 +1,213 @@ +"""用户端应用路由。 + +面向普通用户暴露“应用中心”能力:列出已发布工作流、上传输入创建任务、 +查询进度、重试失败任务以及下载产物。用户只看到输入 -> 进度 -> 结果。 +""" + +from __future__ import annotations + +import json +import shutil +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from wov_app.db import Database + +router = APIRouter(tags=["apps"]) + + +def _now_iso() -> str: + """返回当前 UTC 时间的 ISO 格式字符串。""" + return datetime.now(timezone.utc).isoformat() + + +def _get_db() -> Database: + """从 FastAPI 应用状态中延迟获取数据库实例。""" + from wov_app.main import app + + return app.state.db + + +@router.get("/api/apps") +def list_apps(db: Database = Depends(_get_db)) -> list[dict]: + """返回全部已发布工作流及其最新版本定义。""" + apps = [] + for workflow in db.list_workflows(): + # 草稿工作流不对用户端可见。 + if not workflow["published"]: + continue + latest = db.get_latest_workflow_version(workflow["id"]) + apps.append( + { + "id": workflow["id"], + "name": workflow["name"], + "description": workflow["description"], + "version": workflow["latest_version"], + "definition": latest["definition"] if latest else None, + } + ) + return apps + + +@router.post("/api/apps/{workflow_id}/runs") +async def create_run( + workflow_id: str, + file: UploadFile = File(...), + params: str = Form(default=""), + db: Database = Depends(_get_db), +) -> dict: + """接收用户上传文件,创建排队中的工作流任务。""" + workflow = db.get_workflow(workflow_id) + # 只允许对已发布且存在版本的工作流发起任务。 + if workflow is None or not workflow["published"]: + raise HTTPException(status_code=404, detail="published workflow not found") + + latest = db.get_latest_workflow_version(workflow_id) + if latest is None: + raise HTTPException(status_code=422, detail="workflow has no version") + + run_id = f"run_{uuid.uuid4().hex[:12]}" + # 使用安全文件名,避免路径穿越。 + filename = Path(file.filename or "upload.bin").name + from wov_app.config import STORAGE_DIR + + # 上传文件按 run 隔离存放,调度器通过 input_uri 引用。 + input_dir = STORAGE_DIR / "uploads" / run_id + input_dir.mkdir(parents=True, exist_ok=True) + input_uri = input_dir / filename + content = await file.read() + input_uri.write_bytes(content) + + # 可选参数覆盖(如前端框选的 crop):{节点ID: {参数: 值}},随任务持久化。 + param_overrides = None + if params.strip(): + try: + parsed = json.loads(params) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=422, detail="params must be valid JSON") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=422, detail="params must be a JSON object") + param_overrides = parsed + + now = _now_iso() + db.create_run( + { + "id": run_id, + "workflow_id": workflow_id, + "workflow_version": latest["version"], + "status": "QUEUED", + "progress": 0, + "input_uri": str(input_uri), + "param_overrides": param_overrides, + "created_at": now, + "updated_at": now, + } + ) + return { + "id": run_id, + "status": "QUEUED", + "progress": 0, + "artifacts": [], + } + + +@router.get("/api/runs") +def list_runs(db: Database = Depends(_get_db)) -> list[dict]: + """返回最近的运行记录,供任务管理页展示。""" + return db.list_runs() + + +@router.get("/api/runs/{run_id}") +def get_run(run_id: str, db: Database = Depends(_get_db)) -> dict: + """返回任务详情,并附带当前产物列表。""" + run = db.get_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail="run not found") + run["artifacts"] = db.list_artifacts(run_id) + return run + + +@router.post("/api/runs/{run_id}/retry") +def retry_run(run_id: str, db: Database = Depends(_get_db)) -> dict: + """重置失败任务为排队状态,清空旧产物后重新执行。""" + run = db.get_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail="run not found") + if run["status"] != "FAILED": + raise HTTPException(status_code=422, detail="only failed runs can be retried") + # reset_run 会清空进度、错误和旧产物,确保从头开始。 + db.reset_run(run_id, _now_iso()) + return {"id": run_id, "status": "QUEUED"} + + +@router.post("/api/runs/{run_id}/pause") +def pause_run(run_id: str, db: Database = Depends(_get_db)) -> dict: + """暂停任务:排队中或运行中的任务可暂停,运行中的任务在节点边界停下。""" + run = db.get_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail="run not found") + if run["status"] not in ("QUEUED", "RUNNING"): + raise HTTPException(status_code=422, detail="only queued or running runs can be paused") + db.pause_run(run_id, _now_iso()) + return {"id": run_id, "status": "PAUSED"} + + +@router.post("/api/runs/{run_id}/resume") +def resume_run(run_id: str, db: Database = Depends(_get_db)) -> dict: + """继续任务:暂停的任务恢复排队,由调度器从断点继续执行。""" + run = db.get_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail="run not found") + if run["status"] != "PAUSED": + raise HTTPException(status_code=422, detail="only paused runs can be resumed") + db.resume_run(run_id, _now_iso()) + return {"id": run_id, "status": "QUEUED"} + +@router.delete("/api/runs/{run_id}") +def delete_run(run_id: str, db: Database = Depends(_get_db)) -> dict: + """删除任务:清理产物记录、上传文件与步骤产物目录。""" + run = db.get_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail="run not found") + from wov_app.config import STORAGE_DIR + + # 先删数据库记录(含产物表),再清理磁盘上的上传与中间产物。 + db.delete_run(run_id) + input_uri = run.get("input_uri") + if input_uri: + # 上传文件位于 /uploads//,整目录一并删除。 + shutil.rmtree(Path(input_uri).parent, ignore_errors=True) + # 步骤产物位于 /runs//,整目录一并删除。 + shutil.rmtree(STORAGE_DIR / "runs" / run_id, ignore_errors=True) + return {"deleted": run_id} + +@router.get("/api/runs/{run_id}/artifacts") +def list_run_artifacts(run_id: str, db: Database = Depends(_get_db)) -> list[dict]: + """返回任务全部产物记录。""" + if db.get_run(run_id) is None: + raise HTTPException(status_code=404, detail="run not found") + return db.list_artifacts(run_id) + + +@router.get("/api/runs/{run_id}/artifacts/{artifact_name}") +def download_artifact( + run_id: str, + artifact_name: str, + db: Database = Depends(_get_db), +) -> FileResponse: + """按任务与产物名下载文件,文件缺失时返回 404。""" + artifact = db.get_artifact(run_id, artifact_name) + if artifact is None: + raise HTTPException(status_code=404, detail="artifact not found") + path = Path(artifact["uri"]) + if not path.is_file(): + raise HTTPException(status_code=404, detail="artifact file missing") + return FileResponse( + path, + media_type=artifact["mime_type"], + filename=path.name, + ) diff --git a/src/wov_app/routers/workflows.py b/src/wov_app/routers/workflows.py new file mode 100755 index 0000000..2c2160f --- /dev/null +++ b/src/wov_app/routers/workflows.py @@ -0,0 +1,140 @@ +"""工作流管理路由。 + +提供工作流的创建、查询、校验、发布和删除能力。工作流以版本化 DAG 数据保存, +不写死在业务代码中。 +""" + +from __future__ import annotations + +import re +import uuid + +from fastapi import APIRouter, Depends, HTTPException + +from wov_app.db import Database +from wov_app.schemas import WorkflowCreate +from wov_sdk.models import WorkflowDefinition + +router = APIRouter(prefix="/api/admin/workflows", tags=["workflows"]) + + +def _get_db() -> Database: + """从应用状态延迟获取数据库实例。""" + from wov_app.main import app + + return app.state.db + + +def _slugify(value: str) -> str: + """把工作流名称转换为小写连字符 ID;无有效字符时生成随机 ID。""" + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or uuid.uuid4().hex[:8] + + +def _validate_definition(raw: dict) -> WorkflowDefinition: + """解析并校验 DAG 定义,非法时转换为 422 HTTP 异常。""" + try: + definition = WorkflowDefinition.from_dict(raw) + definition.validate() + return definition + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.get("") +def list_workflows(db: Database = Depends(_get_db)) -> list[dict]: + """返回全部工作流概要。""" + return db.list_workflows() + + +@router.post("") +def create_workflow( + payload: WorkflowCreate, + db: Database = Depends(_get_db), +) -> dict: + """创建新工作流或为已有工作流追加一个版本。""" + definition = _validate_definition(payload.definition) + # 未显式指定 ID 时由名称生成;已有工作流则版本号递增。 + workflow_id = payload.id or _slugify(payload.name) + existing = db.get_workflow(workflow_id) + version = (existing or {}).get("latest_version", 0) + 1 + # 每次创建都保存新版本,发布操作只切换 published 标记。 + db.upsert_workflow( + { + "id": workflow_id, + "name": payload.name, + "description": payload.description, + "published": 0, + "latest_version": version, + } + ) + db.create_workflow_version(workflow_id, version, definition.to_dict()) + return { + "id": workflow_id, + "name": payload.name, + "description": payload.description, + "published": False, + "latest_version": version, + } + + +@router.get("/{workflow_id}") +def get_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict: + """返回工作流概要及最新版本定义。""" + workflow = db.get_workflow(workflow_id) + if workflow is None: + raise HTTPException(status_code=404, detail="workflow not found") + latest = db.get_latest_workflow_version(workflow_id) + workflow["latest_version_data"] = latest + return workflow + + +@router.delete("/{workflow_id}") +def delete_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict: + """删除工作流及其版本、任务和产物记录。""" + if db.get_workflow(workflow_id) is None: + raise HTTPException(status_code=404, detail="workflow not found") + db.delete_workflow(workflow_id) + return {"deleted": workflow_id} + + +@router.post("/{workflow_id}/validate") +def validate_workflow( + workflow_id: str, + definition: dict, + db: Database = Depends(_get_db), +) -> dict: + """在不保存的情况下校验一份 DAG 定义。""" + if db.get_workflow(workflow_id) is None: + raise HTTPException(status_code=404, detail="workflow not found") + parsed = _validate_definition(definition) + return {"valid": True, "node_ids": [node.id for node in parsed.nodes]} + + +@router.post("/{workflow_id}/publish") +def publish_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict: + """把工作流标记为已发布,使其出现在用户应用中心。""" + workflow = db.get_workflow(workflow_id) + if workflow is None: + raise HTTPException(status_code=404, detail="workflow not found") + if workflow["latest_version"] == 0: + raise HTTPException(status_code=422, detail="workflow has no version") + # 发布只是状态切换,不修改已保存的版本数据。 + db.upsert_workflow( + { + "id": workflow_id, + "name": workflow["name"], + "description": workflow["description"], + "published": 1, + "latest_version": workflow["latest_version"], + } + ) + return {"published": workflow_id} + + +@router.get("/{workflow_id}/versions") +def list_versions(workflow_id: str, db: Database = Depends(_get_db)) -> list[dict]: + """返回工作流全部版本定义。""" + if db.get_workflow(workflow_id) is None: + raise HTTPException(status_code=404, detail="workflow not found") + return db.list_workflow_versions(workflow_id) diff --git a/src/wov_app/scheduler.py b/src/wov_app/scheduler.py new file mode 100755 index 0000000..c4c41e1 --- /dev/null +++ b/src/wov_app/scheduler.py @@ -0,0 +1,308 @@ +"""工作流调度器。 + +轮询 SQLite 中的排队任务,按工作流 DAG 的拓扑顺序依次调用进程内节点 +处理器,并把节点产物登记为任务产物。单体版使用单线程顺序执行,节点在 +同一进程内直接调用,不再经过子进程与 HTTP 协议。 +""" + +from __future__ import annotations + +import threading +import time +from datetime import datetime, timezone +from pathlib import Path + +from wov_sdk.models import InvokeRequest, WorkflowDefinition + +from wov_app import registry +from wov_app.config import SCHEDULER_INTERVAL_SECONDS +from wov_app.db import Database +from wov_app.logging import get_logger + +# 调度器运行日志:节点进度、暂停/续跑等状态变化。 +logger = get_logger("scheduler") + +def _now_iso() -> str: + """返回当前 UTC 时间的 ISO 格式字符串。""" + return datetime.now(timezone.utc).isoformat() + + +def topological_sort(definition: WorkflowDefinition) -> list[str]: + """对工作流 DAG 做拓扑排序,返回可执行的节点 ID 顺序。""" + nodes = {node.id: node for node in definition.nodes} + # 统计每个节点的入度,并记录依赖关系。 + indegree = {node_id: 0 for node_id in nodes} + dependents: dict[str, list[str]] = {node_id: [] for node_id in nodes} + + for edge in definition.edges: + # 边引用了不存在的节点时直接报错。 + if edge.from_node not in nodes or edge.to_node not in nodes: + raise ValueError(f"unknown edge: {edge.from_node} -> {edge.to_node}") + indegree[edge.to_node] += 1 + dependents[edge.from_node].append(edge.to_node) + + # Kahn 算法:从入度为 0 的节点开始逐层取出。 + queue = [node_id for node_id, degree in indegree.items() if degree == 0] + ordered: list[str] = [] + while queue: + current = queue.pop(0) + ordered.append(current) + for dependent in dependents[current]: + indegree[dependent] -= 1 + if indegree[dependent] == 0: + queue.append(dependent) + + # 排序结果数量不足说明存在环,无法确定执行顺序。 + if len(ordered) != len(nodes): + raise ValueError("workflow contains a cycle") + return ordered + + +class WorkflowScheduler: + """后台任务调度器:单线程轮询并执行排队中的工作流运行。""" + + def __init__( + self, + db: Database, + storage_dir: Path, + interval_seconds: float | None = None, + ) -> None: + """保存依赖并初始化轮询线程控制字段。""" + self.db = db + self.storage_dir = storage_dir + self.interval_seconds = interval_seconds or SCHEDULER_INTERVAL_SECONDS + self._thread: threading.Thread | None = None + self._stopping = False + + def start(self) -> None: + """启动调度线程;重复调用无副作用。""" + if self._thread is not None: + return + self._stopping = False + self._thread = threading.Thread( + target=self._loop, + name="wov-workflow-scheduler", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + """请求停止并等待轮询线程退出。""" + self._stopping = True + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None + + def _loop(self) -> None: + """轮询循环:有排队任务就立即执行,否则休眠一个间隔。""" + while not self._stopping: + run = self.db.next_queued_run() + if run is not None: + self.execute_run(run["id"]) + else: + time.sleep(self.interval_seconds) + + def _resolve_ref( + self, + ref: str, + run_input_uri: str | None, + outputs_by_node: dict[str, dict[str, str]], + ) -> str | None: + """解析输入引用:input.xxx 取任务入口,node.key 取前序节点产物。""" + # 入口引用以 input. 为前缀。 + if ref.startswith("input."): + return run_input_uri + # 其余引用必须形如 "节点ID.输出名"。 + node_id, separator, key = ref.partition(".") + if not separator: + return None + return outputs_by_node.get(node_id, {}).get(key) + + def execute_run(self, run_id: str) -> None: + """执行单个任务:加载 DAG、按拓扑顺序调用节点并登记产物。""" + run = self.db.get_run(run_id) + # 任务不存在或不在可执行状态(排队/暂停)时直接返回,避免重复执行。 + if run is None or run["status"] not in ("QUEUED", "PAUSED"): + return + + # 工作流或版本记录丢失时把任务标记为失败。 + workflow = self.db.get_workflow(run["workflow_id"]) + if workflow is None: + self.db.update_run(run_id, status="FAILED", error="workflow not found", updated_at=_now_iso()) + return + + version = self.db.get_workflow_version(run["workflow_id"], run["workflow_version"]) + if version is None: + self.db.update_run(run_id, status="FAILED", error="workflow version not found", updated_at=_now_iso()) + return + + # 解析并校验 DAG,随后计算拓扑执行顺序。 + definition = WorkflowDefinition.from_dict(version["definition"]) + definition.validate() + ordered = topological_sort(definition) + # 从已登记产物重建已完成节点的输出,支持暂停后断点续跑。 + outputs_by_node = self.db.restore_run_outputs(run_id) + run_started = time.monotonic() + self.db.update_run(run_id, status="RUNNING", progress=0, updated_at=_now_iso()) + try: + for index, node_id in enumerate(ordered): + # 暂停检查:用户暂停后调度器在节点边界停下,保持 PAUSED 等待续跑。 + current = self.db.get_run(run_id) + if current is None or current["status"] == "PAUSED": + logger.info("任务 %s 已暂停,停止在节点 %s 之前", run_id, node_id) + return + # 断点续跑:跳过已产出结果的节点(其产物已作为输入可用)。 + if node_id in outputs_by_node: + continue + # 当前节点进度 = 已完成节点数 / 总节点数。 + node_spec = next(item for item in definition.nodes if item.id == node_id) + self.db.update_run( + run_id, + current_node_id=node_id, + progress=index / len(ordered), + updated_at=_now_iso(), + ) + # 解析节点声明的每个输入引用,缺任一输入即失败。 + invoke_inputs: dict[str, str] = {} + for input_name, ref in node_spec.inputs.items(): + value = self._resolve_ref(ref, run.get("input_uri"), outputs_by_node) + if value is None: + raise ValueError(f"missing input {input_name} for node {node_id}") + invoke_inputs[input_name] = value + + # 前端框选的 crop 等参数覆盖:按节点 ID 合并进节点参数。 + node_params = dict(node_spec.params) + overrides = run.get("param_overrides") or {} + node_params.update(overrides.get(node_id, {})) + + # 每个任务的每个节点使用独立产物目录,避免并发冲突。 + node_started = time.monotonic() + output_dir = ( + self.storage_dir + / "runs" + / run_id + / "steps" + / node_id + ) + response = registry.invoke( + node_spec.node_type, + InvokeRequest( + run_id=run_id, + node_instance_id="", + inputs=invoke_inputs, + params=node_params, + output_dir=str(output_dir), + ), + ) + # 节点返回非 completed 即视为步骤失败。 + if response.status != "completed": + raise RuntimeError(response.error or f"node {node_id} failed") + + # 记录节点输出,供后续节点引用和最终产物映射使用。 + outputs_by_node[node_id] = { + str(key): str(value) for key, value in response.outputs.items() + } + for key, uri in outputs_by_node[node_id].items(): + # 产物名带节点前缀,例如 asr.srt_uri,避免跨节点重名。 + artifact = { + "run_id": run_id, + "node_id": node_id, + "name": f"{node_id}.{key}", + "uri": uri, + "mime_type": self._mime_type(uri), + "size": self._file_size(uri), + } + self.db.create_artifact(artifact) + # 进度日志:节点序号/总数、耗时与任务累计运行时间(数据速度可观测)。 + logger.info( + "任务 %s 进度 %d/%d 节点: %s 耗时 %.1fs, 运行累计 %.1fs", + run_id, index + 1, len(ordered), node_id, + time.monotonic() - node_started, + time.monotonic() - run_started, + ) + # 处理 final_outputs,为用户端提供简洁的下载别名。 + for alias, ref in definition.final_outputs.items(): + resolved = self._resolve_ref(ref, run.get("input_uri"), outputs_by_node) + if resolved is not None: + # 最终产物按 上传文件名.标识.时间戳 重命名,区分语言与版本。 + resolved = self._final_artifact_uri(resolved, run, definition, alias, ref) + self.db.create_artifact( + { + "run_id": run_id, + "node_id": ref.partition(".")[0], + "name": alias, + "uri": resolved, + "mime_type": self._mime_type(resolved), + "size": self._file_size(resolved), + } + ) + + # 全部节点成功后标记完成;期间被暂停则保持 PAUSED,等待续跑补做收尾。 + if self.db.get_run(run_id)["status"] == "PAUSED": + logger.info("任务 %s 节点全部完成但已暂停,保持 PAUSED", run_id) + return + self.db.update_run( + run_id, + status="COMPLETED", + current_node_id=None, + progress=1.0, + updated_at=_now_iso(), + ) + except Exception as exc: # noqa: BLE001 + # 任一步骤异常都结束任务并记录错误,等待用户重试。 + self.db.update_run( + run_id, + status="FAILED", + error=str(exc), + updated_at=_now_iso(), + ) + + def _final_artifact_uri( + self, + resolved: str, + run: dict, + definition: WorkflowDefinition, + alias: str, + ref: str, + ) -> str: + """把最终产物重命名为 上传文件名.标识.时间戳 并返回新 URI。 + + 标识优先取产出节点的 target_language 参数(如 zh-CN),否则回退为 + 产物别名;时间戳取当前时刻,用于区分同一上传文件的多次运行版本。 + 重命名在原地进行(同目录),不复制文件。 + """ + source = Path(resolved) + # 续跑等场景下源文件可能已被上次收尾重命名过:不再重命名,原样返回。 + if not source.is_file(): + return resolved + # 基础名来自上传文件名;无上传文件时退回通用名称 subtitle。 + base = Path(run["input_uri"]).stem if run.get("input_uri") else "subtitle" + # 通过最终输出引用定位产出节点,取其语言参数作为标识。 + node_id = ref.partition(".")[0] + node = next((item for item in definition.nodes if item.id == node_id), None) + tag = (node.params.get("target_language") if node else None) or alias + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + new_path = source.with_name(f"{base}.{tag}.{timestamp}{source.suffix}") + source.rename(new_path) + return str(new_path) + + @staticmethod + def _mime_type(uri: str) -> str: + """按扩展名推断产物 MIME 类型,未知类型使用通用二进制类型。""" + path = Path(uri) + suffix = path.suffix.lower() + return { + ".srt": "application/x-subrip", + ".ass": "text/plain", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".txt": "text/plain", + }.get(suffix, "application/octet-stream") + + @staticmethod + def _file_size(uri: str) -> int: + """读取产物文件大小;文件缺失时按 0 处理。""" + try: + return Path(uri).stat().st_size + except OSError: + return 0 diff --git a/src/wov_app/schemas.py b/src/wov_app/schemas.py new file mode 100644 index 0000000..b15bc86 --- /dev/null +++ b/src/wov_app/schemas.py @@ -0,0 +1,22 @@ +"""FastAPI 请求/响应 schema。 + +使用 Pydantic 模型校验管理 API 的 JSON 请求体。节点管理功能已移除,仅保留 +工作流相关请求模型。 +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class WorkflowCreate(BaseModel): + """创建工作流或新增版本的请求体。""" + + # 缺省时由后端根据名称生成 slug ID。 + id: str | None = None + name: str = Field(min_length=1) + description: str = "" + # DAG 原始字典,后端会解析并校验为 WorkflowDefinition。 + definition: dict[str, Any] diff --git a/src/wov_app/seed.py b/src/wov_app/seed.py new file mode 100644 index 0000000..38072e1 --- /dev/null +++ b/src/wov_app/seed.py @@ -0,0 +1,48 @@ +"""种子数据模块。 + +从 workflows/*.json 数据文件加载默认工作流并写入数据库(幂等)。 +工作流定义是**数据**(JSON):切换模型、调整链路只改数据文件,不涉及代码, +满足"工作流即数据"与"切换模型不改代码"的设计约束。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from wov_sdk.models import WorkflowDefinition + +from wov_app.config import WORKSPACE_ROOT +from wov_app.db import Database + + +def seed_default_workflows(db: Database, workflows_dir: Path | None = None) -> int: + """从数据目录加载默认工作流,已存在的工作流跳过,返回创建数量。 + + 每个 JSON 文件结构: + {"id", "name", "description", "version", "definition"}, + definition 为 WorkflowDefinition 的标准 DAG 字典。 + """ + workflows_dir = workflows_dir or (WORKSPACE_ROOT / "workflows") + created = 0 + for path in sorted(workflows_dir.glob("*.json")): + payload = json.loads(path.read_text(encoding="utf-8")) + workflow_id = str(payload["id"]) + # 已存在的工作流不覆盖,避免启动时反复改写用户数据。 + if db.get_workflow(workflow_id) is not None: + continue + definition = WorkflowDefinition.from_dict(payload["definition"]) + definition.validate() + version = int(payload.get("version", 1)) + db.upsert_workflow( + { + "id": workflow_id, + "name": str(payload["name"]), + "description": str(payload.get("description", "")), + "published": 1, + "latest_version": version, + } + ) + db.create_workflow_version(workflow_id, version, definition.to_dict()) + created += 1 + return created diff --git a/src/wov_sdk/__init__.py b/src/wov_sdk/__init__.py new file mode 100644 index 0000000..3911f0c --- /dev/null +++ b/src/wov_sdk/__init__.py @@ -0,0 +1,28 @@ +"""WOV SDK 公共导出入口。 + +单体版中调度器、节点与 API 统一从 wov_sdk 导入协议模型,而无需关心具体 +模块路径。协议数据模型保持与分布式版一致,为将来回退保留兼容桥梁。 +""" + +from wov_sdk.models import ( + HealthResponse, + InvokeRequest, + InvokeResponse, + NodeManifest, + ProgressEvent, + WorkflowDefinition, + WorkflowEdge, + WorkflowNode, +) + +# 对外稳定的公共 API 清单;新增模型时必须同步追加到这里。 +__all__ = [ + "HealthResponse", + "InvokeRequest", + "InvokeResponse", + "NodeManifest", + "ProgressEvent", + "WorkflowDefinition", + "WorkflowEdge", + "WorkflowNode", +] diff --git a/src/wov_sdk/models.py b/src/wov_sdk/models.py new file mode 100755 index 0000000..eab17b1 --- /dev/null +++ b/src/wov_sdk/models.py @@ -0,0 +1,321 @@ +"""WOV 节点协议核心数据模型。 + +本模块定义节点 Manifest、调用请求/响应、健康检查、进度事件以及工作流 DAG 的 +通用数据结构。单体版中调度器、节点与 API 共用这些类,字段语义必须长期保持 +稳定,新增能力时只能向后兼容地扩展字段。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + + +def _require_non_empty(value: str, name: str) -> None: + """校验必填字符串字段,空字符串或纯空白字符串都会被拒绝。""" + if not value or not value.strip(): + raise ValueError(f"{name} must not be empty") + + +@dataclass +class NodeManifest: + """节点注册清单:描述节点能力、输入输出与资源参数。 + + 该清单由 manifests/ 目录下的 JSON 文件提供,单体启动时注册到进程内 + 节点注册表,调度器据此把 node_type 解析到对应的 invoke 处理器。 + """ + + # 节点稳定唯一 ID,例如 faster-whisper;注册后不可随意更改。 + id: str + # 展示名称,仅用于管理后台等界面。 + name: str + # 节点版本号,与节点仓库 Git tag 保持一致。 + version: str + # 能力标识,工作流通过 node_type 引用能力,而不是直接绑定具体仓库。 + capability: str + # 启动命令;单体版不再启动子进程,字段保留仅为协议兼容。 + command: list[str] + # 节点代码所在目录;单体版保留仅为协议兼容。 + repo_dir: str = "." + # 节点环境变量;单体版保留仅为协议兼容。 + env: dict[str, str] = field(default_factory=dict) + # 输入字段 schema,当前主要用于文档展示,后续可用于运行时校验。 + input_schema: dict[str, Any] = field(default_factory=dict) + # 输出字段 schema,用于描述节点产物的名称与类型。 + output_schema: dict[str, Any] = field(default_factory=dict) + # 单进程内无并发槽位概念,字段保留仅为协议兼容。 + max_concurrency: int = 1 + # 单体内模型常驻不回收,字段保留仅为协议兼容。 + idle_ttl_seconds: int = 300 + # 无进程启动等待,字段保留仅为协议兼容。 + health_timeout_seconds: int = 10 + # 单体内模型常驻不回收,字段保留仅为协议兼容。 + keep_warm: bool = False + + def validate(self) -> None: + """校验 manifest 必填字段与数值边界,非法配置抛出 ValueError。""" + _require_non_empty(self.id, "id") + _require_non_empty(self.name, "name") + _require_non_empty(self.version, "version") + _require_non_empty(self.capability, "capability") + _require_non_empty(self.repo_dir, "repo_dir") + # 命令不能为空,否则节点进程无法启动。 + if not self.command: + raise ValueError("command must not be empty") + if self.max_concurrency < 1: + raise ValueError("max_concurrency must be >= 1") + if self.idle_ttl_seconds < 0: + raise ValueError("idle_ttl_seconds must be >= 0") + if self.health_timeout_seconds < 1: + raise ValueError("health_timeout_seconds must be >= 1") + + def to_dict(self) -> dict[str, Any]: + """转换为可 JSON 序列化的普通字典。""" + return { + "id": self.id, + "name": self.name, + "version": self.version, + "capability": self.capability, + "command": self.command, + "repo_dir": self.repo_dir, + "env": self.env, + "input_schema": self.input_schema, + "output_schema": self.output_schema, + "max_concurrency": self.max_concurrency, + "idle_ttl_seconds": self.idle_ttl_seconds, + "health_timeout_seconds": self.health_timeout_seconds, + "keep_warm": self.keep_warm, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "NodeManifest": + """从注册 API 或 JSON 文件解析出的字典恢复 manifest。""" + return cls( + id=str(data["id"]), + name=str(data["name"]), + version=str(data["version"]), + capability=str(data["capability"]), + # command 可能缺省,解析时提供空列表兜底。 + command=[str(item) for item in data.get("command", [])], + repo_dir=str(data.get("repo_dir", ".")), + env={str(k): str(v) for k, v in data.get("env", {}).items()}, + input_schema=dict(data.get("input_schema", {})), + output_schema=dict(data.get("output_schema", {})), + # 数值字段缺省时使用与 dataclass 一致的默认值。 + max_concurrency=int(data.get("max_concurrency", 1)), + idle_ttl_seconds=int(data.get("idle_ttl_seconds", 300)), + health_timeout_seconds=int(data.get("health_timeout_seconds", 10)), + keep_warm=bool(data.get("keep_warm", False)), + ) + + @classmethod + def load(cls, path: str) -> "NodeManifest": + """从磁盘上的 node.manifest.json 加载并校验 manifest。""" + with open(path, "r", encoding="utf-8") as f: + manifest = cls.from_dict(json.load(f)) + manifest.validate() + return manifest + + +@dataclass +class InvokeRequest: + """节点调用请求:由调度器或管理后台发送给节点 HTTP 服务。""" + + # 工作流运行 ID,用于追踪一次完整执行。 + run_id: str + # 实际承载本次调用的节点实例 ID,由 NodeManager 回填。 + node_instance_id: str + # 输入产物映射,key 为输入名,value 为产物 URI 或直接文本。 + inputs: dict[str, Any] = field(default_factory=dict) + # 节点运行参数,例如采样率、语言、模型路径等。 + params: dict[str, Any] = field(default_factory=dict) + # 节点产物输出目录。 + output_dir: str = "." + + def to_dict(self) -> dict[str, Any]: + """转换为可 JSON 序列化的普通字典。""" + return { + "run_id": self.run_id, + "node_instance_id": self.node_instance_id, + "inputs": self.inputs, + "params": self.params, + "output_dir": self.output_dir, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InvokeRequest": + """从 HTTP 请求 JSON 解析调用请求。""" + return cls( + run_id=str(data["run_id"]), + node_instance_id=str(data["node_instance_id"]), + inputs=dict(data.get("inputs", {})), + params=dict(data.get("params", {})), + output_dir=str(data.get("output_dir", ".")), + ) + + +@dataclass +class InvokeResponse: + """节点调用响应:completed 表示成功,failed 表示执行失败。""" + + # 执行状态,固定为 completed / failed。 + status: str + # 输出产物映射,key 为输出名,value 为产物 URI。 + outputs: dict[str, Any] = field(default_factory=dict) + # 失败原因,仅在 failed 时有意义。 + error: str | None = None + + def to_dict(self) -> dict[str, Any]: + """转换为可 JSON 序列化的普通字典。""" + return { + "status": self.status, + "outputs": self.outputs, + "error": self.error, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InvokeResponse": + """从 HTTP 响应 JSON 解析调用结果。""" + return cls( + # 缺省按失败处理,避免未知状态被误判为成功。 + status=str(data.get("status", "failed")), + outputs=dict(data.get("outputs", {})), + error=data.get("error"), + ) + + +@dataclass +class HealthResponse: + """节点健康检查响应:节点进程就绪后返回 ok 与自身标识。""" + + status: str + node_id: str + version: str + + def to_dict(self) -> dict[str, Any]: + """转换为可 JSON 序列化的普通字典。""" + return { + "status": self.status, + "node_id": self.node_id, + "version": self.version, + } + + +@dataclass +class ProgressEvent: + """进度事件:预留用于节点向调度器上报执行进度。""" + + run_id: str + node_id: str + progress: float + message: str | None = None + + def to_dict(self) -> dict[str, Any]: + """转换为可 JSON 序列化的普通字典。""" + return { + "run_id": self.run_id, + "node_id": self.node_id, + "progress": self.progress, + "message": self.message, + } + + +@dataclass +class WorkflowNode: + """工作流中的一个节点:声明节点类型、参数和输入引用。""" + + # 节点在 DAG 内的唯一 ID,例如 extract、asr。 + id: str + # 引用的节点能力,例如 ffmpeg-extract、faster-whisper。 + node_type: str + # 传递给节点 invoke 的 params。 + params: dict[str, Any] = field(default_factory=dict) + # 输入引用,value 形如 "前序节点ID.输出名" 或 "input.入口字段"。 + inputs: dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """转换为可 JSON 序列化的普通字典。""" + return { + "id": self.id, + "node_type": self.node_type, + "params": self.params, + "inputs": self.inputs, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "WorkflowNode": + """从工作流定义 JSON 解析节点。""" + return cls( + id=str(data["id"]), + node_type=str(data["node_type"]), + params=dict(data.get("params", {})), + inputs={str(k): str(v) for k, v in data.get("inputs", {}).items()}, + ) + + +@dataclass +class WorkflowEdge: + """工作流有向边:from_node 的输出流向 to_node 的输入。""" + + from_node: str + to_node: str + + def to_dict(self) -> dict[str, Any]: + """转换为 JSON 时使用 from/to 短字段名。""" + return {"from": self.from_node, "to": self.to_node} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "WorkflowEdge": + """从工作流定义 JSON 解析边。""" + return cls(from_node=str(data["from"]), to_node=str(data["to"])) + + +@dataclass +class WorkflowDefinition: + """工作流 DAG 定义:包含节点列表、依赖边和输入输出映射。""" + + name: str + version: int + nodes: list[WorkflowNode] = field(default_factory=list) + edges: list[WorkflowEdge] = field(default_factory=list) + # 用户上传入口与入口字段名的映射,例如 {"video_uri": "file"}。 + entry_inputs: dict[str, Any] = field(default_factory=dict) + # 最终对外暴露的产物别名映射,例如 {"ass": "ass.ass_uri"}。 + final_outputs: dict[str, Any] = field(default_factory=dict) + + def validate(self) -> None: + """校验 DAG 基本约束:名称、版本、节点 ID 唯一、边引用有效。""" + _require_non_empty(self.name, "name") + if self.version < 1: + raise ValueError("version must be >= 1") + # 节点 ID 集合用于检查重复和边引用。 + node_ids = {node.id for node in self.nodes} + if len(node_ids) != len(self.nodes): + raise ValueError("workflow node ids must be unique") + for edge in self.edges: + if edge.from_node not in node_ids or edge.to_node not in node_ids: + raise ValueError(f"edge references unknown node: {edge}") + + def to_dict(self) -> dict[str, Any]: + """转换为可 JSON 序列化的普通字典。""" + return { + "name": self.name, + "version": self.version, + "nodes": [node.to_dict() for node in self.nodes], + "edges": [edge.to_dict() for edge in self.edges], + "entry_inputs": self.entry_inputs, + "final_outputs": self.final_outputs, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "WorkflowDefinition": + """从工作流定义 JSON 解析 DAG。""" + return cls( + name=str(data["name"]), + version=int(data.get("version", 1)), + nodes=[WorkflowNode.from_dict(item) for item in data.get("nodes", [])], + edges=[WorkflowEdge.from_dict(item) for item in data.get("edges", [])], + entry_inputs=dict(data.get("entry_inputs", {})), + final_outputs=dict(data.get("final_outputs", {})), + ) diff --git a/testdata/ocr_notext.png b/testdata/ocr_notext.png new file mode 100644 index 0000000..0086edb Binary files /dev/null and b/testdata/ocr_notext.png differ diff --git a/testdata/ocr_text.png b/testdata/ocr_text.png new file mode 100644 index 0000000..ce2c298 Binary files /dev/null and b/testdata/ocr_text.png differ diff --git a/testdata/speech_60s.wav b/testdata/speech_60s.wav new file mode 100644 index 0000000..b4e4765 Binary files /dev/null and b/testdata/speech_60s.wav differ diff --git a/testdata/subtitle_10s.mp4 b/testdata/subtitle_10s.mp4 new file mode 100644 index 0000000..ca84227 Binary files /dev/null and b/testdata/subtitle_10s.mp4 differ diff --git a/testdata/test_real_hav_sub.png b/testdata/test_real_hav_sub.png new file mode 100644 index 0000000..a977852 Binary files /dev/null and b/testdata/test_real_hav_sub.png differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..10653bd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +"""pytest 全局配置。 + +在测试进程启动时创建独立临时目录,并通过环境变量把应用的数据目录、数据库、 +存储和后台服务全部指向测试环境,避免污染本地开发数据;同时隔离进程内节点 +注册表,防止测试之间互相泄漏注册条目。 +""" + +import atexit +import os +import shutil +import tempfile +from pathlib import Path + +import pytest + +# 每个测试进程使用独立临时根目录,保证测试之间互不干扰。 +TEST_ROOT = Path(tempfile.mkdtemp(prefix="vrsub-test-")) +os.environ["WOV_DATA_DIR"] = str(TEST_ROOT / "data") +os.environ["WOV_DB_PATH"] = str(TEST_ROOT / "data" / "wov.db") +os.environ["WOV_STORAGE_DIR"] = str(TEST_ROOT / "storage") +# 默认关闭自动种子和后台调度,测试显式控制执行时机。 +os.environ["WOV_AUTO_SEED"] = "0" +os.environ["WOV_SCHEDULER_ENABLED"] = "0" +os.environ["WOV_CLEANUP_ENABLED"] = "0" + + +def _cleanup() -> None: + """进程退出时清理临时测试目录。""" + shutil.rmtree(TEST_ROOT, ignore_errors=True) + + +atexit.register(_cleanup) + + +@pytest.fixture(autouse=True) +def _isolate_registry(): + """快照并恢复进程内节点注册表,避免测试之间互相污染。""" + from wov_app import registry + + snapshot = dict(registry._registry) + yield + registry._registry.clear() + registry._registry.update(snapshot) diff --git a/tests/test_adaptive_pool.py b/tests/test_adaptive_pool.py new file mode 100644 index 0000000..a13af83 --- /dev/null +++ b/tests/test_adaptive_pool.py @@ -0,0 +1,148 @@ +"""自适应线程池测试。 + +覆盖决策函数(增/减/保持/边界)、map 顺序返回、worker 异常隔离, +以及"10s 窗口内平均响应 < 0.3s 加线程 / > 1.0s 减线程"的弹性行为 +(通过注入假时钟做确定性验证)。 +""" + +import time + +from nodes.adaptive_pool import AdaptiveThreadPool, decide + + +class FakeClock: + """可手动拨动的假时钟,用于确定性验证弹性窗口逻辑。""" + + def __init__(self, now: float = 0.0) -> None: + self.now = now + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +def test_decide_increase_when_fast() -> None: + """平均响应低于 fast_threshold 且未达上限:线程数 +1。""" + assert decide(1, 0.1, 1, 16, 0.3, 1.0) == 2 + + +def test_decide_decrease_when_slow() -> None: + """平均响应高于 slow_threshold 且高于下限:线程数 -1。""" + assert decide(3, 2.0, 1, 16, 0.3, 1.0) == 2 + + +def test_decide_keep_when_mid() -> None: + """平均响应介于两阈值之间:保持不变。""" + assert decide(2, 0.5, 1, 16, 0.3, 1.0) == 2 + + +def test_decide_bounds() -> None: + """已达上限不再增、已达下限不再减。""" + assert decide(16, 0.1, 1, 16, 0.3, 1.0) == 16 + assert decide(1, 2.0, 1, 16, 0.3, 1.0) == 1 + + +def test_pool_map_ordered_results() -> None: + """map 按输入顺序返回结果,worker 简单映射。""" + pool = AdaptiveThreadPool(worker=lambda item: item * 2) + assert pool.map([1, 2, 3, 4]) == [2, 4, 6, 8] + + +def test_pool_on_progress_callback() -> None: + """进度回调:每次完成触发一次,携带已完成数/总数/速度。""" + progress: list[tuple[int, int, float]] = [] + pool = AdaptiveThreadPool( + worker=lambda item: item, + on_progress=lambda done, total, rate: progress.append((done, total, rate)), + ) + pool.map([10, 20, 30]) + assert [item[0] for item in progress] == [1, 2, 3] # 已完成数递增。 + assert all(item[1] == 3 for item in progress) # 总数固定。 + assert all(item[2] > 0 for item in progress) # 速度为正值。 + +def test_pool_map_empty() -> None: + """空输入:不启动任务,直接返回空列表。""" + pool = AdaptiveThreadPool(worker=lambda item: item) + assert pool.map([]) == [] + + +def test_pool_worker_exception_isolated() -> None: + """worker 抛异常时以异常对象作为结果,不拖垮整体。""" + def boom(item): + raise RuntimeError("boom") + + pool = AdaptiveThreadPool(worker=boom) + results = pool.map([1, 2]) + assert len(results) == 2 + assert all(isinstance(result, RuntimeError) for result in results) + + +def test_pool_grows_when_fast() -> None: + """10s 窗口内平均响应 < 0.3s:线程数从 1 增至 2(弹性扩容)。""" + clock = FakeClock() + pool = AdaptiveThreadPool( + worker=lambda item: item, + min_workers=1, max_workers=16, + window_seconds=10.0, fast_threshold=0.3, + clock=clock, + ) + # 拨快时钟越过窗口:首个任务完成即触发评估 → 平均响应≈0 < 0.3 → +1 线程。 + clock.advance(11) + pool.map(list(range(4))) + assert pool.max_concurrency == 2 + + +def test_pool_shrink_when_slow() -> None: + """窗口平均响应 > 1.0s:线程数从 2 减至 1(弹性退避)。""" + clock = FakeClock() + pool = AdaptiveThreadPool( + worker=lambda item: item, + min_workers=1, max_workers=16, + window_seconds=10.0, fast_threshold=0.3, slow_threshold=1.0, + clock=clock, + ) + pool._resize(2) # 先扩到 2 个线程。 + clock.advance(11) + pool._tick(2.0) # 窗口内平均 2.0 > 1.0 → 缩回 1。 + deadline = time.monotonic() + 2 + while len(pool._threads) > 1 and time.monotonic() < deadline: + time.sleep(0.01) + assert len(pool._threads) == 1 + pool._stop.set() + + +def test_resize_shrink_idempotent() -> None: + """回归:重复缩容到同一目标不会重复放哨兵(曾因并发缩容毒死全部线程而死锁)。""" + pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=8) + pool._resize(3) + assert pool._target_workers == 3 + pool._resize(2) + pool._resize(2) # 目标已是 2:幂等,不再放哨兵。 + assert pool._target_workers == 2 + # 只有 1 个线程被哨兵退出,最终存活 2 个。 + deadline = time.monotonic() + 2 + while len(pool._threads) > 2 and time.monotonic() < deadline: + time.sleep(0.01) + assert len(pool._threads) == 2 + pool._stop.set() + + +def test_pool_survives_mixed_grow_shrink() -> None: + """回归:扩容+缩容混合场景 map 必须完成且保序(修复前会死锁挂起)。""" + clock = FakeClock() + state = {"count": 0} + + def worker(item): + state["count"] += 1 + clock.advance(0.06 if state["count"] <= 20 else 0.6) + return item + + pool = AdaptiveThreadPool( + worker=worker, min_workers=1, max_workers=4, + window_seconds=0.5, fast_threshold=0.2, slow_threshold=0.4, + clock=clock, + ) + out = pool.map(list(range(60))) + assert out == list(range(60)) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e29c247 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,34 @@ +"""应用级 API 冒烟测试。 + +使用 FastAPI TestClient 验证健康检查、静态页面与 OpenAPI 文档可访问。 +节点注册/实例管理 API 已随单体化移除,不再有对应路由。 +""" + +from fastapi.testclient import TestClient + +from wov_app.main import app + + +def test_health_and_static() -> None: + """验证静态首页、OpenAPI 文档与健康探针均可访问。""" + with TestClient(app) as client: + root = client.get("/", follow_redirects=False) + assert root.status_code == 200 + assert "VRSub 字幕生成" in root.text + + docs = client.get("/docs") + assert docs.status_code == 200 + + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["service"] == "wov-api" + assert response.json()["mode"] == "monolith" + + +def test_node_admin_routes_removed() -> None: + """验证节点注册与实例管理路由在单体版中已移除(404/405)。""" + with TestClient(app) as client: + # GET 落到静态文件挂载后返回 404;POST 对静态挂载返回 405。 + assert client.post("/api/admin/nodes", json={}).status_code == 405 + assert client.get("/api/admin/nodes").status_code == 404 + assert client.get("/api/admin/node-instances").status_code == 404 diff --git a/tests/test_apps_api.py b/tests/test_apps_api.py new file mode 100644 index 0000000..4567f3e --- /dev/null +++ b/tests/test_apps_api.py @@ -0,0 +1,313 @@ +"""用户应用 API 测试。 + +覆盖已发布应用的上传建任务、进度查询、产物下载、失败重试以及 +未发布/无版本工作流的拒绝逻辑。节点为内置注册,无需再手动注册。 +""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from wov_app.main import app + + +def _create_published_echo_workflow(client) -> str: + """创建一个已发布的单节点 Echo 工作流(echo 为内置节点)。""" + definition = { + "name": "echo-flow", + "version": 1, + "nodes": [ + { + "id": "step", + "node_type": "echo", + "inputs": {"file_uri": "input.video_uri"}, + } + ], + "edges": [], + "entry_inputs": {"video_uri": "file"}, + "final_outputs": {"result": "step.file_uri"}, + } + client.post( + "/api/admin/workflows", + json={ + "id": "echo-app", + "name": "Echo App", + "description": "upload a file", + "definition": definition, + }, + ) + client.post("/api/admin/workflows/echo-app/publish") + return "echo-app" + + +def test_upload_run_progress_and_download() -> None: + """验证上传文件建任务、手动执行、查询产物与下载的完整流程。""" + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + apps = client.get("/api/apps") + assert apps.status_code == 200 + assert any(item["id"] == workflow_id for item in apps.json()) + + uploaded = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"hello from upload", "text/plain")}, + ) + assert uploaded.status_code == 200 + run_id = uploaded.json()["id"] + assert uploaded.json()["status"] == "QUEUED" + + run = client.get(f"/api/runs/{run_id}") + assert run.status_code == 200 + assert run.json()["input_uri"].endswith("sample.txt") + assert run.json()["artifacts"] == [] + + scheduler = app.state.scheduler + scheduler.execute_run(run_id) + + completed = client.get(f"/api/runs/{run_id}") + assert completed.status_code == 200 + assert completed.json()["status"] == "COMPLETED" + artifact_names = [item["name"] for item in completed.json()["artifacts"]] + assert "result" in artifact_names + + artifacts = client.get(f"/api/runs/{run_id}/artifacts") + assert artifacts.status_code == 200 + assert len(artifacts.json()) >= 1 + + downloaded = client.get(f"/api/runs/{run_id}/artifacts/result") + assert downloaded.status_code == 200 + assert b"hello from upload" in downloaded.content + + assert client.get(f"/api/runs/{run_id}/artifacts/missing").status_code == 404 + assert client.get("/api/runs/missing").status_code == 404 + assert client.get("/api/runs/missing/artifacts").status_code == 404 + + db = app.state.db + db.create_artifact( + { + "run_id": run_id, + "node_id": "step", + "name": "missing-file", + "uri": str(Path(__file__).resolve().parent / "not-exists.bin"), + "mime_type": "text/plain", + "size": 0, + } + ) + assert client.get(f"/api/runs/{run_id}/artifacts/missing-file").status_code == 404 + + runs = client.get("/api/runs") + assert runs.status_code == 200 + assert any(item["id"] == run_id for item in runs.json()) + + +def test_upload_rejects_unpublished_workflow() -> None: + """验证草稿或不存在的工作流不能被用户发起任务。""" + with TestClient(app) as client: + client.post( + "/api/admin/workflows", + json={ + "id": "draft", + "name": "Draft", + "definition": { + "name": "Draft", + "version": 1, + "nodes": [], + "edges": [], + }, + }, + ) + response = client.post( + "/api/apps/draft/runs", + files={"file": ("x.txt", b"x", "text/plain")}, + ) + assert response.status_code == 404 + + response = client.post( + "/api/apps/missing/runs", + files={"file": ("x.txt", b"x", "text/plain")}, + ) + assert response.status_code == 404 + + +def test_upload_rejects_workflow_without_version() -> None: + """验证已发布但没有任何版本的工作流返回 422。""" + with TestClient(app) as client: + db = app.state.db + db.upsert_workflow( + {"id": "empty", "name": "Empty", "published": 1, "latest_version": 0} + ) + response = client.post( + "/api/apps/empty/runs", + files={"file": ("x.txt", b"x", "text/plain")}, + ) + assert response.status_code == 422 + + +def test_retry_failed_run_requeues_and_reruns() -> None: + """验证失败任务重试会清空旧产物并重新执行成功。""" + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + uploaded = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"hello retry", "text/plain")}, + ) + run_id = uploaded.json()["id"] + db = app.state.db + db.update_run( + run_id, + status="FAILED", + error="boom", + updated_at="2026-01-01T00:00:00+00:00", + ) + db.create_artifact( + { + "run_id": run_id, + "node_id": "step", + "name": "stale", + "uri": "stale.txt", + "mime_type": "text/plain", + "size": 1, + } + ) + + response = client.post(f"/api/runs/{run_id}/retry") + + assert response.status_code == 200 + assert response.json() == {"id": run_id, "status": "QUEUED"} + run = client.get(f"/api/runs/{run_id}").json() + assert run["status"] == "QUEUED" + assert run["error"] is None + assert run["artifacts"] == [] + + app.state.scheduler.execute_run(run_id) + completed = client.get(f"/api/runs/{run_id}").json() + assert completed["status"] == "COMPLETED" + assert any(item["name"] == "result" for item in completed["artifacts"]) + + +def test_pause_resume_run_api() -> None: + """验证暂停/继续接口:QUEUED→PAUSED→QUEUED,状态非法时报 422。""" + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + uploaded = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"hello pause", "text/plain")}, + ) + run_id = uploaded.json()["id"] + assert uploaded.json()["status"] == "QUEUED" + + paused = client.post(f"/api/runs/{run_id}/pause") + assert paused.status_code == 200 + assert paused.json() == {"id": run_id, "status": "PAUSED"} + assert client.get(f"/api/runs/{run_id}").json()["status"] == "PAUSED" + + resumed = client.post(f"/api/runs/{run_id}/resume") + assert resumed.status_code == 200 + assert resumed.json() == {"id": run_id, "status": "QUEUED"} + assert client.get(f"/api/runs/{run_id}").json()["status"] == "QUEUED" + + # 非 PAUSED 任务不可继续。 + assert client.post(f"/api/runs/{run_id}/resume").status_code == 422 + # 不存在的任务 404。 + assert client.post("/api/runs/missing/pause").status_code == 404 + assert client.post("/api/runs/missing/resume").status_code == 404 + + +def test_pause_rejects_terminal_states() -> None: + """验证已完成任务不可暂停。""" + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + uploaded = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"hello done", "text/plain")}, + ) + run_id = uploaded.json()["id"] + db = app.state.db + db.update_run(run_id, status="COMPLETED", progress=1.0, updated_at="2026-01-01T00:00:00+00:00") + assert client.post(f"/api/runs/{run_id}/pause").status_code == 422 + + +def test_retry_rejects_non_failed_and_missing_runs() -> None: + """验证只有 FAILED 状态且存在的任务才能重试。""" + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + uploaded = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"x", "text/plain")}, + ) + run_id = uploaded.json()["id"] + + assert client.post(f"/api/runs/{run_id}/retry").status_code == 422 + assert client.post("/api/runs/missing/retry").status_code == 404 + + +def test_delete_run_removes_record_and_files() -> None: + """验证删除任务会清理数据库记录与磁盘上的上传/步骤文件。""" + import shutil + + from wov_app.config import STORAGE_DIR + + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + uploaded = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"hello delete", "text/plain")}, + ) + run_id = uploaded.json()["id"] + + # 执行任务以生成步骤产物目录。 + app.state.scheduler.execute_run(run_id) + run = client.get(f"/api/runs/{run_id}").json() + steps_dir = STORAGE_DIR / "runs" / run_id + assert steps_dir.is_dir() + # 上传文件目录也应存在。 + upload_dir = Path(run["input_uri"]).parent + assert upload_dir.is_dir() + + deleted = client.delete(f"/api/runs/{run_id}") + assert deleted.status_code == 200 + assert deleted.json() == {"deleted": run_id} + + assert client.get(f"/api/runs/{run_id}").status_code == 404 + assert not steps_dir.exists() + assert not upload_dir.exists() + + # 删除不存在的任务返回 404。 + assert client.delete(f"/api/runs/missing").status_code == 404 + + # 清理测试遗留的 runs 目录,避免跨用例残留。 + shutil.rmtree(STORAGE_DIR / "runs", ignore_errors=True) + + +def test_create_run_with_param_overrides() -> None: + """验证创建任务时可携带 params 覆盖(如前端框选的 crop),并持久化。""" + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + uploaded = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"x", "text/plain")}, + data={"params": '{"step": {"crop": [0, 0.82, 1, 0.18]}}'}, + ) + assert uploaded.status_code == 200 + run_id = uploaded.json()["id"] + run = client.get(f"/api/runs/{run_id}").json() + assert run["param_overrides"] == {"step": {"crop": [0, 0.82, 1, 0.18]}} + # 非法 JSON 返回 422。 + bad = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"x", "text/plain")}, + data={"params": "not-json"}, + ) + assert bad.status_code == 422 + + +def test_create_run_params_non_object_rejected() -> None: + """params 为 JSON 数组时返回 422。""" + with TestClient(app) as client: + workflow_id = _create_published_echo_workflow(client) + response = client.post( + f"/api/apps/{workflow_id}/runs", + files={"file": ("sample.txt", b"x", "text/plain")}, + data={"params": "[1,2,3]"}, + ) + assert response.status_code == 422 diff --git a/tests/test_crop_js.js b/tests/test_crop_js.js new file mode 100644 index 0000000..b86e668 --- /dev/null +++ b/tests/test_crop_js.js @@ -0,0 +1,32 @@ +// crop 归一化纯函数单测:由 pytest 通过 node 执行(TDD 红阶段先失败)。 +"use strict"; +const assert = require("assert"); +const { videoDisplayRect, rectToCrop, cropToRect } = require("../web/assets/crop.js"); + +// 1) 无留边(容器比例与视频一致):底部 20% 矩形 → crop [0, 0.8, 1, 0.2] +let d = videoDisplayRect(1280, 720, 1280, 720); +assert.deepStrictEqual(d, { x: 0, y: 0, w: 1280, h: 720 }); +assert.deepStrictEqual( + rectToCrop({ x: 0, y: 576, w: 1280, h: 144 }, 1280, 720, 1280, 720), + [0, 0.8, 1, 0.2] +); + +// 2) letterbox(容器比视频宽):视频显示在中间,矩形映射要考虑左右留边 +d = videoDisplayRect(1280, 720, 1600, 720); +assert.deepStrictEqual(d, { x: 160, y: 0, w: 1280, h: 720 }); +// 在渲染视频内框选右下 25% 区域 +let crop = rectToCrop({ x: 160 + 640, y: 360, w: 640, h: 360 }, 1280, 720, 1600, 720); +assert.deepStrictEqual(crop, [0.5, 0.5, 0.5, 0.5]); + +// 3) 回显一致性:crop → rect → crop 应还原(含 letterbox) +let back = cropToRect(crop, 1280, 720, 1600, 720); +assert.deepStrictEqual( + rectToCrop(back, 1280, 720, 1600, 720), + crop +); + +// 4) 越界钳制:矩形超出画面时 crop 值被限制在 0~1 +crop = rectToCrop({ x: -100, y: -50, w: 2000, h: 900 }, 1280, 720, 1280, 720); +assert.ok(crop.every((v) => v >= 0 && v <= 1)); + +console.log("crop.js 全部断言通过"); diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..18c3e47 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,294 @@ +"""数据库层单元测试。 + +直接对 Database 方法调用真实 SQLite 路径,覆盖工作流、版本、任务与产物的 +增删改查。节点注册表已改为进程内内存态,不再落库。 +""" + +from pathlib import Path + +from wov_app.db import Database + + +def test_workflow_crud(tmp_path) -> None: + """验证工作流概要的插入、发布标记更新与删除。""" + db = Database(tmp_path / "wov.db") + workflow = { + "id": "demo", + "name": "Demo", + "description": "desc", + "published": 0, + "latest_version": 0, + } + db.upsert_workflow(workflow) + assert db.get_workflow("demo")["name"] == "Demo" + assert [item["id"] for item in db.list_workflows()] == ["demo"] + + db.upsert_workflow({**workflow, "published": 1, "latest_version": 1}) + assert db.get_workflow("demo")["published"] == 1 + + db.delete_workflow("demo") + assert db.get_workflow("demo") is None + + +def test_workflow_versions(tmp_path) -> None: + """验证工作流版本的写入、最新版本查询与列表。""" + db = Database(tmp_path / "wov.db") + db.upsert_workflow( + {"id": "demo", "name": "Demo", "published": 1, "latest_version": 2} + ) + definition = {"name": "Demo", "version": 1, "nodes": [], "edges": []} + db.create_workflow_version("demo", 1, definition) + db.create_workflow_version("demo", 2, {**definition, "version": 2}) + + latest = db.get_latest_workflow_version("demo") + assert latest["version"] == 2 + assert latest["definition"]["version"] == 2 + + version = db.get_workflow_version("demo", 1) + assert version["version"] == 1 + assert db.get_workflow_version("demo", 99) is None + assert len(db.list_workflow_versions("demo")) == 2 + + empty_db = Database(tmp_path / "empty.db") + assert empty_db.get_latest_workflow_version("missing") is None + + +def test_run_and_artifact_crud(tmp_path) -> None: + """验证任务与产物的创建、查询、更新与删除。""" + db = Database(tmp_path / "wov.db") + db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_1", + "workflow_id": "demo", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": "in.txt", + "created_at": now, + "updated_at": now, + } + ) + assert db.get_run("run_1")["status"] == "QUEUED" + assert db.next_queued_run()["id"] == "run_1" + + db.update_run("run_1", status="RUNNING", progress=0.5, updated_at=now) + db.update_run("run_1") + assert db.get_run("run_1")["status"] == "RUNNING" + assert db.get_run("run_1")["progress"] == 0.5 + assert db.next_queued_run() is None + assert len(db.list_runs()) == 1 + + db.create_artifact( + { + "run_id": "run_1", + "node_id": "echo", + "name": "result", + "uri": "out.txt", + "mime_type": "text/plain", + "size": 3, + } + ) + assert db.get_artifact("run_1", "result")["uri"] == "out.txt" + assert db.get_artifact("run_1", "missing") is None + assert len(db.list_artifacts("run_1")) == 1 + + db.delete_run_artifacts("run_1") + assert db.list_artifacts("run_1") == [] + + +def test_reset_run_clears_error_and_artifacts(tmp_path) -> None: + """验证 reset_run 会把失败任务恢复到排队状态并清空旧产物。""" + db = Database(tmp_path / "wov.db") + db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_1", + "workflow_id": "demo", + "workflow_version": 1, + "status": "FAILED", + "progress": 0.75, + "current_node_id": "translate", + "error": "timed out", + "input_uri": "in.txt", + "created_at": now, + "updated_at": now, + } + ) + db.create_artifact( + { + "run_id": "run_1", + "node_id": "asr", + "name": "asr.srt_uri", + "uri": "out.srt", + "mime_type": "application/x-subrip", + "size": 3, + } + ) + + db.reset_run("run_1", "2026-01-02T00:00:00+00:00") + + run = db.get_run("run_1") + assert run["status"] == "QUEUED" + assert run["progress"] == 0 + assert run["current_node_id"] is None + assert run["error"] is None + assert run["updated_at"] == "2026-01-02T00:00:00+00:00" + assert run["created_at"] == now + assert db.list_artifacts("run_1") == [] + + +def test_delete_run(tmp_path) -> None: + """验证 delete_run 会删除任务记录及其产物记录。""" + db = Database(tmp_path / "wov.db") + db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_1", + "workflow_id": "demo", + "workflow_version": 1, + "status": "COMPLETED", + "progress": 1, + "input_uri": "in.txt", + "created_at": now, + "updated_at": now, + } + ) + db.create_artifact( + { + "run_id": "run_1", + "node_id": "asr", + "name": "asr.srt_uri", + "uri": "out.srt", + "mime_type": "application/x-subrip", + "size": 3, + } + ) + db.delete_run("run_1") + assert db.get_run("run_1") is None + assert db.list_artifacts("run_1") == [] + + +def test_list_run_ids(tmp_path) -> None: + """验证 list_run_ids 返回全部任务 ID,供孤儿清理对照使用。""" + db = Database(tmp_path / "wov.db") + db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + assert db.list_run_ids() == [] + for run_id in ("run_a", "run_b"): + db.create_run( + { + "id": run_id, + "workflow_id": "demo", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "created_at": now, + "updated_at": now, + } + ) + assert set(db.list_run_ids()) == {"run_a", "run_b"} + + +def test_run_param_overrides_persist(tmp_path) -> None: + """验证 param_overrides 随任务持久化并可读回。""" + db = Database(tmp_path / "wov.db") + db.upsert_workflow({"id": "demo", "name": "D", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_ov", + "workflow_id": "demo", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "param_overrides": {"extract": {"crop": [0, 0.5, 1, 0.5]}}, + "created_at": now, + "updated_at": now, + } + ) + run = db.get_run("run_ov") + assert run["param_overrides"] == {"extract": {"crop": [0, 0.5, 1, 0.5]}} + assert db.next_queued_run()["param_overrides"] == {"extract": {"crop": [0, 0.5, 1, 0.5]}} + + +def test_db_migration_adds_param_overrides(tmp_path) -> None: + """旧库迁移:缺少 param_overrides 列的库打开后自动补列。""" + import sqlite3 + + db_path = tmp_path / "old.db" + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE workflow_runs (id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL," + " workflow_version INTEGER NOT NULL, status TEXT NOT NULL, current_node_id TEXT," + " progress REAL NOT NULL DEFAULT 0, error TEXT, input_uri TEXT," + " created_at TEXT NOT NULL, updated_at TEXT NOT NULL)" + ) + conn.commit() + conn.close() + + Database(db_path) + conn = sqlite3.connect(db_path) + columns = [row[1] for row in conn.execute("PRAGMA table_info(workflow_runs)")] + conn.close() + assert "param_overrides" in columns + + +def test_pause_resume_run(tmp_path) -> None: + """验证 pause_run/resume_run 的状态流转与 PAUSED 任务可被调度器取到。""" + db = Database(tmp_path / "wov.db") + db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_p", + "workflow_id": "demo", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "created_at": now, + "updated_at": now, + } + ) + db.pause_run("run_p", now) + assert db.get_run("run_p")["status"] == "PAUSED" + # PAUSED 任务会被 next_queued_run 取到(等待续跑)。 + assert db.next_queued_run()["id"] == "run_p" + db.resume_run("run_p", now) + assert db.get_run("run_p")["status"] == "QUEUED" + assert db.next_queued_run()["id"] == "run_p" + + +def test_restore_run_outputs(tmp_path) -> None: + """验证从产物重建节点输出(断点续跑的依据)。""" + db = Database(tmp_path / "wov.db") + db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_r", + "workflow_id": "demo", + "workflow_version": 1, + "status": "PAUSED", + "progress": 0, + "created_at": now, + "updated_at": now, + } + ) + db.create_artifact( + { + "run_id": "run_r", + "node_id": "extract", + "name": "frames_manifest", + "uri": "frames.json", + "mime_type": "application/json", + "size": 1, + } + ) + assert db.restore_run_outputs("run_r") == { + "extract": {"frames_manifest": "frames.json"} + } + assert db.restore_run_outputs("run_none") == {} diff --git a/tests/test_frontend_crop.py b/tests/test_frontend_crop.py new file mode 100644 index 0000000..7fefaa0 --- /dev/null +++ b/tests/test_frontend_crop.py @@ -0,0 +1,28 @@ +"""前端 crop 归一化纯函数测试。 + +通过 node 执行 tests/test_crop_js.js(真实 JS 断言),验证框选矩形与 +crop 比例的互转(含 letterbox 与越界钳制)。 +""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +# 单体根目录:tests/ 的上一级。 +WORKSPACE = Path(__file__).resolve().parent.parent +JS_TEST = WORKSPACE / "tests" / "test_crop_js.js" + + +def test_crop_js_normalization() -> None: + """node 执行 crop 纯函数断言(TDD 红阶段先失败)。""" + if shutil.which("node") is None: + pytest.skip("环境无 node,跳过前端 crop 单测") + result = subprocess.run( + ["node", str(JS_TEST)], + capture_output=True, + text=True, + cwd=str(WORKSPACE), + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_integration_subtitle_ocr.py b/tests/test_integration_subtitle_ocr.py new file mode 100644 index 0000000..6106ac2 --- /dev/null +++ b/tests/test_integration_subtitle_ocr.py @@ -0,0 +1,90 @@ +"""字幕 OCR 整链真实集成测试。 + +使用 testdata/subtitle_10s.mp4(烧录 SUB 001@1-4s、SUB 002@6-9s)与真实 +glm-ocr 模型:抽帧(frame-extract)→ 逐帧 OCR(subtitle-ocr)→ 汇总 SRT, +断言烧录文字与时间轴对齐。Ollama 服务或资产缺失时自动跳过。 +""" + +import json +import urllib.request +from pathlib import Path + +import pytest + +from nodes.frame_extract import invoke as frame_invoke +from nodes.subtitle_ocr import invoke as ocr_invoke +from wov_sdk.models import InvokeRequest + +OLLAMA_HOST = "http://192.168.123.70:11434" +MODEL = "glm-ocr:latest" + +# 单体根目录:tests/ 的上一级。 +WORKSPACE = Path(__file__).resolve().parent.parent +VIDEO = WORKSPACE / "testdata" / "subtitle_10s.mp4" + + +def _register_nodes() -> None: + """注册全部内置节点,供 subtitle-ocr 内部调 vlm-ocr 使用。""" + from wov_app import registry + + registry.register_all() + + +def _ollama_reachable() -> bool: + """探测 Ollama 服务与目标模型是否可用。""" + try: + req = urllib.request.Request( + f"{OLLAMA_HOST}/api/show", + data=b'{"model": "%s"}' % MODEL.encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status == 200 + except (urllib.error.URLError, OSError): + return False + + +@pytest.mark.integration +def test_subtitle_ocr_full_chain(tmp_path) -> None: + """抽帧→OCR→汇总:SRT 应含 SUB 001/SUB 002 且时间轴落在各自区间。""" + if not _ollama_reachable(): + pytest.skip("Ollama 服务或 glm-ocr 模型不可用,跳过真实模型集成测试") + if not VIDEO.is_file(): + pytest.skip("缺少 testdata/subtitle_10s.mp4 测试资产,跳过集成测试") + _register_nodes() + + # 抽帧:1s 间隔,字幕在底部,裁切下半 30% 区域(y=0.7,h=0.3)。 + frames_resp = frame_invoke( + InvokeRequest( + run_id="chain_fx", + node_instance_id="", + inputs={"video_uri": str(VIDEO)}, + params={"interval_seconds": 1, "crop": [0, 0.7, 1, 0.3]}, + output_dir=str(tmp_path / "frames"), + ) + ) + assert frames_resp.status == "completed", frames_resp.error + manifest = json.loads(Path(frames_resp.outputs["frames_manifest"]).read_text(encoding="utf-8")) + assert len(manifest) >= 8 + + # OCR 汇总:真实 glm-ocr 逐帧识别。 + ocr_resp = ocr_invoke( + InvokeRequest( + run_id="chain_ocr", + node_instance_id="", + inputs={"frames_manifest": str(frames_resp.outputs["frames_manifest"])}, + params={"model": MODEL, "ollama_host": OLLAMA_HOST, "min_chars": 2}, + output_dir=str(tmp_path / "out"), + ) + ) + assert ocr_resp.status == "completed", ocr_resp.error + srt = Path(ocr_resp.outputs["srt_uri"]).read_text(encoding="utf-8") + # 两条烧录字幕都应被识别(文字可能带噪声,但至少含关键片段)。 + assert "SUB" in srt + # 时间轴:SUB 001 应在 1-4s,SUB 002 应在 6-9s(允许模型/抽帧容差)。 + first_line = next(line for line in srt.splitlines() if "-->" in line) + start = first_line.split(" --> ")[0].replace(",", ".") + hours, minutes, seconds = start.split(":") + total = int(hours) * 3600 + int(minutes) * 60 + float(seconds) + assert total < 5 diff --git a/tests/test_integration_vlm.py b/tests/test_integration_vlm.py new file mode 100644 index 0000000..f444c2a --- /dev/null +++ b/tests/test_integration_vlm.py @@ -0,0 +1,65 @@ +"""VLM OCR 节点真实集成测试。 + +复用 testdata/test_real_hav_sub.png(真实视频字幕截图,一次性入库,避免 +每次测试生成)。调用本地 Ollama 服务(192.168.123.70:11434)的真实 +glm-ocr 模型做 OCR。Ollama 服务或测试资产缺失时自动跳过;可用时必须执行。 +""" +import urllib.request +from pathlib import Path + +import pytest + +from nodes.vlm import invoke +from wov_sdk.models import InvokeRequest + +OLLAMA_HOST = "http://192.168.123.70:11434" +MODEL = "glm-ocr:latest" +# 测试图片(真实视频字幕帧)上应识别出的字幕文本。 +EXPECTED_TEXT = "还有没有什么困扰 或者奇怪的地方吗" + +# 单体根目录:tests/ 的上一级。 +WORKSPACE = Path(__file__).resolve().parent.parent +TEST_IMAGE = WORKSPACE / "testdata" / "test_real_hav_sub.png" + + +def _ollama_reachable() -> bool: + """探测 Ollama 服务与目标模型是否可用。""" + try: + req = urllib.request.Request( + f"{OLLAMA_HOST}/api/show", + data=b'{"model": "%s"}' % MODEL.encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status == 200 + except (urllib.error.URLError, OSError): + return False + + +@pytest.mark.integration +def test_vlm_ocr_real_model(tmp_path) -> None: + """复用真实字幕截图 + 真实 glm-ocr:应识别出关键字幕文本并清洗围栏垃圾。""" + if not _ollama_reachable(): + pytest.skip("Ollama 服务或 glm-ocr 模型不可用,跳过真实模型集成测试") + if not TEST_IMAGE.is_file(): + pytest.skip("缺少 testdata/test_real_hav_sub.png 测试资产,跳过集成测试") + + response = invoke( + InvokeRequest( + run_id="vlm_integration", + node_instance_id="", + inputs={"image_uri": str(TEST_IMAGE)}, + params={"model": MODEL, "ollama_host": OLLAMA_HOST}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + text = response.outputs["text"] + print(text) + # 关键字幕文本应被识别出来。注意 glm-ocr 在此图上存在已知重复循环 bug: + # 识别出正确文本后可能继续循环输出,因此用"包含"断言而非全等, + # 下游 subtitle-ocr 的 max_result_chars 守卫会拦截超长输出。 + assert EXPECTED_TEXT in text + # 围栏垃圾(```)不应出现在输出里。 + assert "```" not in text diff --git a/tests/test_integration_whisper.py b/tests/test_integration_whisper.py new file mode 100644 index 0000000..f3df6f8 --- /dev/null +++ b/tests/test_integration_whisper.py @@ -0,0 +1,52 @@ +"""真实模型集成测试。 + +复用 testdata/speech_60s.wav(真实语音 WAV,一次性生成、入库,避免每次 +测试从视频提取)。使用真实 faster-whisper 模型端到端验证 whisper 节点的 +分块转写与 SRT 生成。本地缺少模型或测试资产时自动跳过;具备条件时必须 +执行,作为对假模型单元测试的校准。 + +约定(见 AGENTS.md「测试与覆盖率」):单元测试允许在模型推理这一 I/O +边界使用返回真实结构的薄桩,但必须配套本集成测试验证真实行为。 +""" + +from pathlib import Path + +import pytest + +from nodes.whisper import invoke +from wov_sdk.models import InvokeRequest + +# 单体根目录:tests/ 的上一级。 +WORKSPACE = Path(__file__).resolve().parent.parent +MODEL_DIR = WORKSPACE / "model" / "faster-whisper-large-v3" +TEST_AUDIO = WORKSPACE / "testdata" / "speech_60s.wav" + + +@pytest.mark.integration +def test_whisper_real_model_chunked_transcription(tmp_path) -> None: + """复用 testdata 语音 + 真实模型:分块转写产出真实 SRT,时间不越出素材范围。""" + if not (MODEL_DIR / "model.bin").is_file(): + pytest.skip("本地无 faster-whisper-large-v3 模型,跳过真实模型集成测试") + if not TEST_AUDIO.is_file(): + pytest.skip("缺少 testdata/speech_60s.wav 测试资产,跳过真实模型集成测试") + + response = invoke( + InvokeRequest( + run_id="integration_1", + node_instance_id="", + inputs={"audio_uri": str(TEST_AUDIO)}, + params={"language": "ja", "chunk_seconds": 60, "vad_filter": False}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + srt_path = Path(response.outputs["srt_uri"]) + assert srt_path.is_file() + srt = srt_path.read_text(encoding="utf-8") + time_lines = [line for line in srt.splitlines() if "-->" in line] + # 60s 语音若含可识别内容,则应有字幕,且时间轴不越出素材时长(允许少量超窗)。 + if time_lines: + last_end = time_lines[-1].split(" --> ")[1].replace(",", ".") + hours, minutes, seconds = last_end.split(":") + total = int(hours) * 3600 + int(minutes) * 60 + float(seconds) + assert total < 90 diff --git a/tests/test_llm_filter.py b/tests/test_llm_filter.py new file mode 100644 index 0000000..b464152 --- /dev/null +++ b/tests/test_llm_filter.py @@ -0,0 +1,257 @@ +"""LLM 字幕过滤节点测试。 + +覆盖 SRT 解析/序列化、±N 上下文窗口组装(纯文本无时间戳、目标标记)、 +LLM 调用(按 I/O 边界 mock urlopen)与删除判定、invoke 全链路与异常路径。 +""" + +import json +import urllib.error +from pathlib import Path + +from nodes.llm_filter import invoke, parse_srt, serialize_srt +from wov_sdk.models import InvokeRequest + +# 4 条字幕的 SRT:第 3 条为"答:"开头的无意义杂项,模拟 OCR 噪声。 +_SRT = ( + "1\n00:00:01,000 --> 00:00:04,000\n还有没有什么困扰\n\n" + "2\n00:00:05,000 --> 00:00:08,000\n或者奇怪的地方吗\n\n" + "3\n00:00:09,000 --> 00:00:12,000\n答:无意义杂项\n\n" + "4\n00:00:13,000 --> 00:00:16,000\n第二句正常字幕\n" +) + + +class FakeResponse: + """模拟 urllib 响应:read() 返回 LLM 兼容接口的 JSON 载荷。""" + + def __init__(self, payload: bytes) -> None: + self._payload = payload + + def read(self) -> bytes: + return self._payload + + def __enter__(self): + return self + + def __exit__(self, *args) -> bool: + return False + + +class FakeLLM: + """模拟 LLM 兼容接口:记录请求体,按策略返回"保留/删除"。 + + 支持两种策略:contents(按队列顺序,用于单次直调 _judge_target 的 + 确定性测试)或 decision_fn(按请求体内容决策,用于并发 invoke 测试, + 保证任何线程执行顺序下判定结果都确定)。 + """ + + def __init__(self, contents: list[str] | None = None, decision_fn=None) -> None: + self._contents = list(contents) if contents is not None else None + self._decision_fn = decision_fn + self.bodies: list[dict] = [] + self.headers: list[dict] = [] + + def __call__(self, request, timeout=None): + body = json.loads(request.data.decode("utf-8")) + self.bodies.append(body) + self.headers.append(dict(request.headers)) + if self._decision_fn is not None: + content = self._decision_fn(body) + else: + content = self._contents.pop(0) + payload = json.dumps({"choices": [{"message": {"content": content}}]}).encode() + return FakeResponse(payload) + + +def _patch_llm(monkeypatch, contents: list[str] | None = None, decision_fn=None) -> FakeLLM: + """替换 nodes.llm_filter 的 urlopen 为 FakeLLM 并返回实例。""" + fake = FakeLLM(contents=contents, decision_fn=decision_fn) + monkeypatch.setattr("nodes.llm_filter.urllib.request.urlopen", fake) + return fake + + +def _decision_by_target(body) -> str: + """按目标字幕内容决策:含"答:"判为删除,其余保留(与 _SRT 的噪声对应)。""" + target = next( + line for line in body["messages"][1]["content"].splitlines() + if line.startswith("【目标】") + ) + return "删除" if "答:" in target else "保留" + + +def test_parse_srt_multiline_and_last_block() -> None: + """解析 SRT:多行文本与末条无空行结尾均能正确解析。""" + text = ( + "1\n00:00:01,000 --> 00:00:04,000\n第一行\n第二行\n\n" + "2\n00:00:05,000 --> 00:00:08,000\n末条无空行结尾\n" + ) + entries = parse_srt(text) + assert len(entries) == 2 + assert entries[0]["start"] == "00:00:01,000" + assert entries[0]["end"] == "00:00:04,000" + assert entries[0]["text"] == "第一行\n第二行" + assert entries[1]["text"] == "末条无空行结尾" + + +def test_serialize_srt_renumbers() -> None: + """序列化:序号从 1 重新编号,保留原始时间轴。""" + entries = [ + {"start": "00:00:09,000", "end": "00:00:12,000", "text": "答:无意义杂项"}, + {"start": "00:00:13,000", "end": "00:00:16,000", "text": "第二句正常字幕"}, + ] + out = serialize_srt(entries) + assert out == ( + "1\n00:00:09,000 --> 00:00:12,000\n答:无意义杂项\n\n" + "2\n00:00:13,000 --> 00:00:16,000\n第二句正常字幕\n" + ) + + +def test_judge_target_window_and_keep(monkeypatch) -> None: + """窗口只含纯文本(无时间戳)、目标带标记;模型答"保留"则返回 False。""" + from nodes.llm_filter import _judge_target + + entries = parse_srt(_SRT) + fake = _patch_llm(monkeypatch, ["保留"]) + # context_size=1,目标为第 2 条(index=1):窗口 0..2 共 3 行,目标在中间。 + assert _judge_target(entries, 1, context_size=1, params={}) is False + body = fake.bodies[0] + lines = body["messages"][1]["content"].splitlines() + assert len(lines) == 3 + assert lines[0] == "还有没有什么困扰" + assert lines[1] == "【目标】或者奇怪的地方吗" + assert lines[2] == "答:无意义杂项" + # 不含时间戳。 + assert "00:00" not in body["messages"][1]["content"] + assert body["enable_thinking"] is False + assert body["max_tokens"] == 16 + + +def test_judge_target_delete(monkeypatch) -> None: + """模型答"删除"时返回 True(判定该条无意义)。""" + from nodes.llm_filter import _judge_target + + entries = parse_srt(_SRT) + _patch_llm(monkeypatch, ["删除"]) + assert _judge_target(entries, 2, context_size=10, params={}) is True + + +def test_judge_target_model_and_auth(monkeypatch) -> None: + """模型名从参数取;配置 API Key 时附带 Bearer 鉴权头。""" + from nodes.llm_filter import _judge_target + + entries = parse_srt(_SRT) + monkeypatch.setenv("LLM_API_KEY", "sk-test") + fake = _patch_llm(monkeypatch, ["保留"]) + assert _judge_target(entries, 0, context_size=10, params={"model": "m/1"}) is False + assert fake.bodies[0]["model"] == "m/1" + assert fake.headers[0]["Authorization"] == "Bearer sk-test" + + +def test_invoke_filters_and_renumbers(monkeypatch, tmp_path) -> None: + """全链路(并发):按 LLM 判定删除无意义条,保留条重新编号输出。""" + srt = tmp_path / "in.srt" + srt.write_text(_SRT, encoding="utf-8") + # 内容决策:目标字幕含"答:"判删除,其余保留(任何线程顺序下结果确定)。 + _patch_llm(monkeypatch, decision_fn=_decision_by_target) + response = invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"srt_uri": str(srt)}, + params={}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + assert response.outputs["kept"] == 3 + assert response.outputs["removed"] == 1 + out = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + assert out.count("-->") == 3 + # 被删除的"答:无意义杂项"(第 3 条)时间轴不再出现。 + assert "00:00:09,000" not in out + # 保留条重新编号且时间轴不变。 + assert out.startswith("1\n00:00:01,000 --> 00:00:04,000\n还有没有什么困扰\n\n2\n") + assert "00:00:13,000 --> 00:00:16,000\n第二句正常字幕\n" in out + + +def test_invoke_context_size_param(monkeypatch, tmp_path) -> None: + """context_size 参数生效:窗口大小=2×context_size+1(两端截断除外)。""" + srt = tmp_path / "in.srt" + srt.write_text(_SRT, encoding="utf-8") + fake = _patch_llm(monkeypatch, decision_fn=_decision_by_target) + response = invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"srt_uri": str(srt)}, + params={"context_size": 1}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + # 找到目标为第 2 条("或者奇怪的地方吗")的请求体:窗口应含 3 行。 + body = next( + b for b in fake.bodies + if "【目标】或者奇怪的地方吗" in b["messages"][1]["content"] + ) + window = body["messages"][1]["content"].splitlines() + assert len(window) == 3 + + +def test_invoke_missing_input(tmp_path) -> None: + """缺少 srt_uri 时返回失败。""" + response = invoke( + InvokeRequest(run_id="r", node_instance_id="", inputs={}, output_dir=str(tmp_path)) + ) + assert response.status == "failed" + assert "srt_uri" in response.error + + +def test_invoke_file_missing(tmp_path) -> None: + """srt 文件不存在时返回失败。""" + response = invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"srt_uri": str(tmp_path / "none.srt")}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + assert "not found" in response.error + + +def test_invoke_llm_error(monkeypatch, tmp_path) -> None: + """LLM 调用失败(网络错误)时返回 failed,不静默输出未过滤结果。""" + srt = tmp_path / "in.srt" + srt.write_text(_SRT, encoding="utf-8") + + def boom(request, timeout=None): + raise urllib.error.URLError("llm down") + + monkeypatch.setattr("nodes.llm_filter.urllib.request.urlopen", boom) + response = invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"srt_uri": str(srt)}, + params={}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "failed" + assert "llm down" in response.error + + +def test_invoke_empty_srt(monkeypatch, tmp_path) -> None: + """空 SRT(无条目)正常完成,输出空文件且不调用 LLM。""" + srt = tmp_path / "empty.srt" + srt.write_text("", encoding="utf-8") + fake = _patch_llm(monkeypatch, []) + response = invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"srt_uri": str(srt)}, + params={}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + assert response.outputs["kept"] == 0 + assert response.outputs["removed"] == 0 + assert fake.bodies == [] diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py new file mode 100644 index 0000000..437d9f6 --- /dev/null +++ b/tests/test_maintenance.py @@ -0,0 +1,165 @@ +"""孤儿数据清理器测试。 + +覆盖 COMPLETED 无文件任务的删除、各类保留分支(有文件/失败/宽限期内)、 +无任务记录的残留目录清理、清理线程启停以及防御性分支。 +""" + +from datetime import datetime, timezone +from pathlib import Path + +from wov_app.db import Database +from wov_app.maintenance import OrphanCleaner + + +def _db(tmp_path) -> Database: + """在临时目录创建独立数据库。""" + return Database(tmp_path / "wov.db") + + +def _make_run(db, run_id, status="COMPLETED", updated="2020-01-01T00:00:00+00:00", input_uri=None): + """创建指定状态与更新时间的工作流任务记录。""" + db.upsert_workflow({"id": "flow", "name": "F", "published": 1, "latest_version": 1}) + db.create_run( + { + "id": run_id, + "workflow_id": "flow", + "workflow_version": 1, + "status": status, + "progress": 1, + "input_uri": input_uri, + "created_at": updated, + "updated_at": updated, + } + ) + + +def _now_iso() -> str: + """返回当前 UTC 时间的 ISO 字符串。""" + return datetime.now(timezone.utc).isoformat() + + +def test_cleaner_removes_completed_orphan_run(tmp_path) -> None: + """验证 COMPLETED 且无任何产物文件、超过宽限期的任务被整体清理。""" + db = _db(tmp_path) + upload_dir = tmp_path / "storage" / "uploads" / "run_orphan" + upload_dir.mkdir(parents=True) + upload_file = upload_dir / "in.mp4" + upload_file.write_bytes(b"x") + _make_run(db, "run_orphan", input_uri=str(upload_file)) + db.create_artifact( + { + "run_id": "run_orphan", + "node_id": "asr", + "name": "asr.srt_uri", + "uri": str(tmp_path / "storage" / "runs" / "run_orphan" / "out.srt"), + "mime_type": "application/x-subrip", + "size": 1, + } + ) + cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600) + assert cleaner.clean_once() == 1 + assert db.get_run("run_orphan") is None + assert db.list_artifacts("run_orphan") == [] + assert not upload_dir.exists() + + +def test_cleaner_keeps_completed_run_with_files(tmp_path) -> None: + """验证仍有产物文件的 COMPLETED 任务不会被清理。""" + db = _db(tmp_path) + steps = tmp_path / "storage" / "runs" / "run_keep" + (steps / "asr").mkdir(parents=True) + (steps / "asr" / "out.srt").write_text("1\n00:00:00,000 --> 00:00:01,000\nok\n", encoding="utf-8") + _make_run(db, "run_keep") + cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600) + assert cleaner.clean_once() == 0 + assert db.get_run("run_keep") is not None + + +def test_cleaner_keeps_failed_and_recent_runs(tmp_path) -> None: + """验证 FAILED 任务与宽限期内的任务都不会被自动清理。""" + db = _db(tmp_path) + _make_run(db, "run_failed", status="FAILED") + _make_run(db, "run_recent", updated=_now_iso()) + cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600) + assert cleaner.clean_once() == 0 + assert db.get_run("run_failed") is not None + assert db.get_run("run_recent") is not None + + +def test_cleaner_removes_dangling_dirs_only(tmp_path) -> None: + """验证无任务记录的残留目录被删除,已有任务的上传目录被保留。""" + db = _db(tmp_path) + ghost_upload = tmp_path / "storage" / "uploads" / "ghost" + ghost_upload.mkdir(parents=True) + ghost_steps = tmp_path / "storage" / "runs" / "ghost" + ghost_steps.mkdir(parents=True) + keep_upload = tmp_path / "storage" / "uploads" / "run_keep" + keep_upload.mkdir(parents=True) + (keep_upload / "in.mp4").write_bytes(b"x") + # run_keep 存在产物文件,不属于孤儿任务。 + keep_steps = tmp_path / "storage" / "runs" / "run_keep" / "asr" + keep_steps.mkdir(parents=True) + (keep_steps / "out.srt").write_text("ok", encoding="utf-8") + _make_run(db, "run_keep") + cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600) + assert cleaner.clean_once() == 2 + assert not ghost_upload.exists() + assert not ghost_steps.exists() + assert keep_upload.exists() + + +def test_cleaner_removes_run_with_empty_steps_dir(tmp_path) -> None: + """验证步骤目录存在但为空(无文件)时仍视为孤儿清理。""" + db = _db(tmp_path) + steps = tmp_path / "storage" / "runs" / "run_empty" + (steps / "asr").mkdir(parents=True) + _make_run(db, "run_empty", input_uri="") + cleaner = OrphanCleaner(db, tmp_path / "storage", grace_seconds=3600) + assert cleaner.clean_once() == 1 + assert db.get_run("run_empty") is None + assert not steps.exists() + + +def test_cleaner_default_config_and_defensive_branches(tmp_path, monkeypatch) -> None: + """验证默认配置构造、缺失/非法时间与缺失任务记录的防御分支。""" + db = _db(tmp_path) + # 默认配置(interval/grace 走 config 默认值)。 + cleaner = OrphanCleaner(db, tmp_path / "storage") + assert cleaner.interval_seconds > 0 + assert cleaner.grace_seconds > 0 + # 无更新时间 / 非法时间均保守视为未过期。 + assert cleaner._expired(None) is False + assert cleaner._expired("not-a-date") is False + # 不存在的根目录直接返回 0。 + assert cleaner._clean_dangling(tmp_path / "missing", set()) == 0 + # list_run_ids 返回的 ID 在读取详情前已不存在时跳过。 + monkeypatch.setattr(db, "list_run_ids", lambda: ["ghost"]) + monkeypatch.setattr(db, "get_run", lambda run_id: None) + assert cleaner.clean_once() == 0 + + +def test_cleaner_start_stop_loop(tmp_path) -> None: + """验证清理线程可启动、周期执行并正常停止。""" + import time + + db = _db(tmp_path) + cleaner = OrphanCleaner(db, tmp_path / "storage", interval_seconds=0.05, grace_seconds=3600) + cleaner.start() + try: + cleaner.start() + time.sleep(0.2) + finally: + cleaner.stop() + assert cleaner._thread is None + + +def test_lifespan_starts_cleaner(monkeypatch) -> None: + """验证启用清理器时应用生命周期会启动清理线程并随退出停止。""" + from fastapi.testclient import TestClient + + from wov_app.main import app + + monkeypatch.setenv("WOV_CLEANUP_ENABLED", "1") + with TestClient(app) as client: + assert client.get("/health").status_code == 200 + assert app.state.cleaner._thread is not None diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100755 index 0000000..3214685 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,174 @@ +"""wov_sdk.models 的单元测试。 + +测试覆盖所有数据模型的 JSON 往返序列化、字段校验和 manifest 文件加载, +确保协议模型的稳定性。 +""" + +import json + +import pytest + +from wov_sdk.models import ( + HealthResponse, + InvokeRequest, + InvokeResponse, + NodeManifest, + ProgressEvent, + WorkflowDefinition, + WorkflowEdge, + WorkflowNode, +) + + +def valid_manifest() -> NodeManifest: + """构造一个覆盖全部字段的合法 NodeManifest,供测试复用。""" + return NodeManifest( + id="echo", + name="Echo", + version="1.0.0", + capability="echo", + command=["python", "-m", "echo"], + repo_dir="wov-node-echo", + env={"PORT": "0"}, + input_schema={"text": "string"}, + output_schema={"text": "string"}, + max_concurrency=2, + idle_ttl_seconds=15, + health_timeout_seconds=5, + keep_warm=True, + ) + + +def test_manifest_round_trip() -> None: + """验证 manifest 经过 to_dict/from_dict 后保持原值。""" + manifest = valid_manifest() + restored = NodeManifest.from_dict(manifest.to_dict()) + assert restored == manifest + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("id", ""), + ("name", ""), + ("version", ""), + ("capability", ""), + ("repo_dir", ""), + ("command", []), + ("max_concurrency", 0), + ("idle_ttl_seconds", -1), + ("health_timeout_seconds", 0), + ], +) +def test_manifest_validation(field: str, value: object) -> None: + """验证必填字段为空或数值越界时抛出 ValueError。""" + manifest = valid_manifest() + setattr(manifest, field, value) + with pytest.raises(ValueError): + manifest.validate() + + +def test_manifest_load(tmp_path) -> None: + """验证 NodeManifest.load 能从 JSON 文件读取并校验。""" + path = tmp_path / "node.manifest.json" + path.write_text(json.dumps(valid_manifest().to_dict()), encoding="utf-8") + loaded = NodeManifest.load(str(path)) + assert loaded.id == "echo" + + +def test_invoke_request_round_trip() -> None: + """验证 InvokeRequest 的 JSON 往返序列化。""" + request = InvokeRequest( + run_id="run_1", + node_instance_id="ni_1", + inputs={"text": "hello"}, + params={"temperature": 0.2}, + output_dir="out", + ) + restored = InvokeRequest.from_dict(request.to_dict()) + assert restored == request + + +def test_invoke_response_round_trip() -> None: + """验证 InvokeResponse 的 JSON 往返序列化。""" + response = InvokeResponse(status="completed", outputs={"text": "hello"}) + restored = InvokeResponse.from_dict(response.to_dict()) + assert restored == response + + +def test_health_and_progress_serialization() -> None: + """验证健康检查和进度事件模型的字典输出。""" + health = HealthResponse(status="ok", node_id="echo", version="1.0.0") + assert health.to_dict() == { + "status": "ok", + "node_id": "echo", + "version": "1.0.0", + } + + progress = ProgressEvent(run_id="run_1", node_id="echo", progress=0.5, message="half") + assert progress.to_dict() == { + "run_id": "run_1", + "node_id": "echo", + "progress": 0.5, + "message": "half", + } + + +def test_workflow_node_and_edge_round_trip() -> None: + """验证工作流节点与边的 JSON 往返序列化。""" + node = WorkflowNode( + id="asr", + node_type="faster-whisper", + params={"language": "ja"}, + inputs={"audio_uri": "extract.audio_uri"}, + ) + edge = WorkflowEdge(from_node="extract", to_node="asr") + assert WorkflowNode.from_dict(node.to_dict()) == node + assert WorkflowEdge.from_dict(edge.to_dict()) == edge + assert edge.to_dict() == {"from": "extract", "to": "asr"} + + assert node.to_dict()["inputs"] == {"audio_uri": "extract.audio_uri"} + + +def test_workflow_definition_round_trip_and_validation() -> None: + """验证完整 DAG 定义可往返序列化并通过校验。""" + definition = WorkflowDefinition( + name="demo", + version=1, + nodes=[ + WorkflowNode(id="extract", node_type="ffmpeg"), + WorkflowNode(id="asr", node_type="whisper"), + ], + edges=[WorkflowEdge(from_node="extract", to_node="asr")], + entry_inputs={"video_uri": "file"}, + final_outputs={"srt": "asr.srt_uri"}, + ) + restored = WorkflowDefinition.from_dict(definition.to_dict()) + assert restored == definition + restored.validate() + + +def test_workflow_definition_invalid() -> None: + """验证非法 DAG(空名、版本为 0、重复节点、未知边)被拒绝。""" + with pytest.raises(ValueError): + WorkflowDefinition(name="", version=1).validate() + + with pytest.raises(ValueError): + WorkflowDefinition(name="demo", version=0).validate() + + duplicate = WorkflowDefinition( + name="demo", + version=1, + nodes=[WorkflowNode(id="a", node_type="x"), WorkflowNode(id="a", node_type="y")], + ) + with pytest.raises(ValueError): + duplicate.validate() + + unknown_edge = WorkflowDefinition( + name="demo", + version=1, + nodes=[WorkflowNode(id="a", node_type="x")], + edges=[WorkflowEdge(from_node="a", to_node="missing")], + ) + with pytest.raises(ValueError): + unknown_edge.validate() diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..1324aa8 --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,1295 @@ +"""进程内节点测试。 + +合并原 5 个节点仓库的测试:echo、ffmpeg、whisper、llm、ass。节点已变为 +进程内模块(nodes/*.py),移除了原入口点(__main__/run_node)相关测试。 +""" + +import base64 +import json +import subprocess +import sys +import threading +import types +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from nodes.ass import invoke as ass_invoke +from nodes.ass import parse_srt, write_ass +from nodes.echo import invoke as echo_invoke +from nodes.ffmpeg import _ffmpeg_bin, invoke as ffmpeg_invoke +from nodes.llm import invoke as llm_invoke +from nodes.llm import translate_lines +from nodes.whisper import format_timestamp +from nodes.whisper import invoke as whisper_invoke +from nodes.whisper import resolve_model_path +from wov_sdk.models import InvokeRequest + +# 单体根目录:tests/ 的上一级。 +WORKSPACE = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# Echo 节点 +# --------------------------------------------------------------------------- + + +def test_echo_invoke_text(tmp_path) -> None: + """验证直接传入 text 时 echo 节点原样写出文本。""" + response = echo_invoke( + InvokeRequest( + run_id="run_1", + node_instance_id="ni_1", + inputs={"text": "hello echo"}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "completed" + assert response.outputs["text"] == "hello echo" + assert Path(response.outputs["file_uri"]).read_text(encoding="utf-8") == "hello echo" + + +def test_echo_invoke_absolute_file(tmp_path) -> None: + """验证绝对路径 file_uri 的文件内容被读取为输入。""" + source = tmp_path / "input.txt" + source.write_text("from file", encoding="utf-8") + response = echo_invoke( + InvokeRequest( + run_id="run_2", + node_instance_id="ni_2", + inputs={"file_uri": str(source)}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed" + assert response.outputs["text"] == "from file" + + +def test_echo_invoke_relative_file(tmp_path) -> None: + """验证相对路径 file_uri 以单体根目录为基准解析。""" + source = WORKSPACE / "relative_input.txt" + source.write_text("relative", encoding="utf-8") + try: + response = echo_invoke( + InvokeRequest( + run_id="run_3", + node_instance_id="ni_3", + inputs={"file_uri": "relative_input.txt"}, + output_dir=str(tmp_path / "out"), + ) + ) + finally: + source.unlink() + assert response.status == "completed" + assert response.outputs["text"] == "relative" + + +def test_echo_invoke_default_text(tmp_path) -> None: + """验证无任何输入时 echo 节点返回默认文本。""" + response = echo_invoke( + InvokeRequest( + run_id="run_4", + node_instance_id="ni_4", + inputs={}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "completed" + assert response.outputs["text"] == "echo" + + +# --------------------------------------------------------------------------- +# FFmpeg 节点 +# --------------------------------------------------------------------------- + + +def _ffmpeg_request(tmp_path, **overrides) -> InvokeRequest: + """构造包含默认视频输入与提音参数的调用请求。""" + payload = { + "run_id": "run_1", + "node_instance_id": "ni_1", + "inputs": {"video_uri": str(tmp_path / "input.mp4")}, + "params": {"sample_rate": 16000, "channels": 1}, + "output_dir": str(tmp_path / "out"), + } + payload.update(overrides) + return InvokeRequest(**payload) + + +def test_ffmpeg_success(tmp_path, monkeypatch) -> None: + """验证成功调用会生成 audio.wav 产物。""" + def fake_run(command, **kwargs): + output = Path(command[-1]) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(b"fake wav") + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr("nodes.ffmpeg.shutil.which", lambda _: "ffmpeg") + monkeypatch.setattr("nodes.ffmpeg.subprocess.run", fake_run) + response = ffmpeg_invoke(_ffmpeg_request(tmp_path)) + assert response.status == "completed" + assert Path(response.outputs["audio_uri"]).name == "audio.wav" + + +def test_ffmpeg_configured_bin(tmp_path, monkeypatch) -> None: + """验证 FFMPEG_BIN 环境变量优先于 PATH 查找。""" + fake_bin = tmp_path / "ffmpeg.exe" + fake_bin.write_bytes(b"") + monkeypatch.setenv("FFMPEG_BIN", str(fake_bin)) + monkeypatch.setattr("nodes.ffmpeg.subprocess.run", lambda *a, **k: subprocess.CompletedProcess([], 0)) + response = ffmpeg_invoke(_ffmpeg_request(tmp_path)) + assert response.status == "completed" + + +def test_ffmpeg_bundled_fallback(tmp_path, monkeypatch) -> None: + """验证无系统 ffmpeg 时回退到 imageio-ffmpeg 内置二进制。""" + monkeypatch.delenv("FFMPEG_BIN", raising=False) + monkeypatch.setattr("nodes.ffmpeg.shutil.which", lambda _: None) + bundled = _ffmpeg_bin() + assert bundled != "ffmpeg" + assert Path(bundled).is_file() + monkeypatch.setattr("nodes.ffmpeg._ffmpeg_bin", lambda: bundled) + + def fake_run(command, **kwargs): + Path(command[-1]).parent.mkdir(parents=True, exist_ok=True) + Path(command[-1]).write_bytes(b"wav") + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr("nodes.ffmpeg.subprocess.run", fake_run) + response = ffmpeg_invoke(_ffmpeg_request(tmp_path)) + assert response.status == "completed" + + +def test_ffmpeg_bundled_import_error(monkeypatch) -> None: + """验证 imageio-ffmpeg 不可用时最终回退为 "ffmpeg" 字符串。""" + monkeypatch.delenv("FFMPEG_BIN", raising=False) + monkeypatch.setattr("nodes.ffmpeg.shutil.which", lambda _: None) + monkeypatch.setitem(sys.modules, "imageio_ffmpeg", None) + assert _ffmpeg_bin() == "ffmpeg" + + +def test_ffmpeg_missing_video_uri(tmp_path) -> None: + """验证缺少 video_uri 时返回失败。""" + response = ffmpeg_invoke(_ffmpeg_request(tmp_path, inputs={})) + assert response.status == "failed" + assert "video_uri" in response.error + + +def test_ffmpeg_missing_bin(tmp_path, monkeypatch) -> None: + """验证找不到任何 ffmpeg 时返回明确失败信息。""" + monkeypatch.delenv("FFMPEG_BIN", raising=False) + monkeypatch.setattr("nodes.ffmpeg.shutil.which", lambda _: None) + monkeypatch.setattr("nodes.ffmpeg._bundled_ffmpeg", lambda: None) + response = ffmpeg_invoke(_ffmpeg_request(tmp_path)) + assert response.status == "failed" + assert "ffmpeg not found" in response.error + + +def test_ffmpeg_failure(tmp_path, monkeypatch) -> None: + """验证 ffmpeg 返回非零退出码时透传 stderr 错误。""" + def fake_run(command, **kwargs): + return subprocess.CompletedProcess(command, 1, stderr="boom") + + monkeypatch.setattr("nodes.ffmpeg.shutil.which", lambda _: "ffmpeg") + monkeypatch.setattr("nodes.ffmpeg.subprocess.run", fake_run) + response = ffmpeg_invoke(_ffmpeg_request(tmp_path)) + assert response.status == "failed" + assert "boom" in response.error + + +# --------------------------------------------------------------------------- +# Whisper 节点 +# --------------------------------------------------------------------------- + + +class FakeSegment: + """模拟 faster-whisper 的分段对象,只提供转写测试需要的字段。""" + + def __init__(self, start, end, text): + self.start = start + self.end = end + self.text = text + + +class FakeWhisperModel: + """记录构造参数并返回固定分段的假 WhisperModel。""" + + instances: list[tuple[tuple, dict]] = [] + + def __init__(self, *args, **kwargs): + # 记录每次构造参数,测试据此断言 device/compute_type 传递。 + FakeWhisperModel.instances.append((args, kwargs)) + self.args = args + self.kwargs = kwargs + + def transcribe(self, path, **kwargs): + # 返回固定两个分段:一个普通时长,一个跨小时验证时间戳格式。 + return ( + [ + FakeSegment(0, 1, "第一段"), + FakeSegment(3600.5, 3602.25, "第二段"), + ], + None, + ) + + +def _install_fake_whisper(monkeypatch, model_class=FakeWhisperModel) -> None: + """把假 faster_whisper 模块注入 sys.modules,替代真实依赖。""" + fake_module = types.SimpleNamespace(WhisperModel=model_class) + monkeypatch.setitem(sys.modules, "faster_whisper", fake_module) + + +def _whisper_request(tmp_path, **overrides) -> InvokeRequest: + """构造默认音频输入与日语参数的调用请求。""" + payload = { + "run_id": "run_1", + "node_instance_id": "ni_1", + "inputs": {"audio_uri": str(tmp_path / "audio.wav")}, + "params": {"language": "ja"}, + "output_dir": str(tmp_path / "out"), + } + payload.update(overrides) + return InvokeRequest(**payload) + + +def test_resolve_explicit_param_wins(tmp_path) -> None: + """验证请求参数中的 model_path 优先级最高,覆盖环境变量与本地候选。""" + local = tmp_path / "model" + local.mkdir() + (local / "model.bin").write_bytes(b"x") + resolved = resolve_model_path( + {"model_path": "/opt/custom-model"}, + env={"WHISPER_MODEL_PATH": "/env/model"}, + candidates=[local], + ) + assert resolved == "/opt/custom-model" + + +def test_resolve_env_wins_over_local(tmp_path) -> None: + """验证 WHISPER_MODEL_PATH 环境变量优先于本地候选目录。""" + local = tmp_path / "model" + local.mkdir() + (local / "model.bin").write_bytes(b"x") + resolved = resolve_model_path( + {}, + env={"WHISPER_MODEL_PATH": "/env/model"}, + candidates=[local], + ) + assert resolved == "/env/model" + + +def test_resolve_local_candidate_used(tmp_path) -> None: + """验证无参数与环境变量时优先使用含 model.bin 的本地候选目录。""" + local = tmp_path / "model" + local.mkdir() + (local / "model.bin").write_bytes(b"x") + resolved = resolve_model_path({}, env={}, candidates=[local]) + assert resolved == str(local) + + +def test_resolve_incomplete_candidate_skipped(tmp_path) -> None: + """验证缺少 model.bin 的候选目录被跳过,避免加载残缺模型。""" + empty = tmp_path / "empty" + empty.mkdir() + resolved = resolve_model_path({}, env={}, candidates=[empty]) + assert resolved == "large-v3" + + +def test_resolve_fallback_remote() -> None: + """验证全部本地候选缺失时回退到远端 large-v3 作为最后兜底。""" + resolved = resolve_model_path({}, env={}, candidates=[]) + assert resolved == "large-v3" + + +def test_format_timestamp() -> None: + """验证秒数到 SRT 时间戳的格式化结果。""" + assert format_timestamp(0) == "00:00:00,000" + assert format_timestamp(3600.5) == "01:00:00,500" + assert format_timestamp(61.25) == "00:01:01,250" + + +def test_whisper_success(tmp_path, monkeypatch) -> None: + """验证成功转写会生成 SRT 并默认使用 auto 设备/计算类型。""" + FakeWhisperModel.instances.clear() + _install_fake_whisper(monkeypatch) + _make_wav(tmp_path / "audio.wav", 5) + response = whisper_invoke(_whisper_request(tmp_path)) + assert response.status == "completed" + content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + assert "第一段" in content + assert "01:00:00,500 --> 01:00:02,250" in content + _, kwargs = FakeWhisperModel.instances[-1] + assert kwargs["device"] == "auto" + assert kwargs["compute_type"] == "auto" + + +def test_load_cuda_libraries_linux(monkeypatch) -> None: + """验证 Linux 下进程内预加载 nvidia 动态库,含失败跳过分支。""" + import ctypes + + from nodes.whisper import _load_cuda_libraries + + # 用真实 nvidia 轮子路径加载,不应抛出异常。 + _load_cuda_libraries() + + # 模拟加载失败分支:部分库抛 OSError 时应被跳过。 + calls: list[str] = [] + real_cdll = ctypes.CDLL + + def fake_cdll(path): + calls.append(str(path)) + if "cudnn" in str(path): + raise OSError("boom") + return real_cdll(path) + + monkeypatch.setattr("nodes.whisper.ctypes.CDLL", fake_cdll) + _load_cuda_libraries() + assert calls + + +def test_load_cuda_libraries_windows(monkeypatch, tmp_path) -> None: + """验证 Windows 分支通过 add_dll_directory 注册 DLL 搜索目录。""" + import os + import sysconfig + + from nodes.whisper import _load_cuda_libraries + + site = tmp_path / "site" + (site / "nvidia" / "cublas" / "bin").mkdir(parents=True) + (site / "nvidia" / "cudnn" / "bin").mkdir(parents=True) + monkeypatch.setattr(sysconfig, "get_paths", lambda: {"purelib": str(site)}) + added: list[str] = [] + # 只注入平台判断与 DLL 目录注册,不改动全局 os.name,避免 pathlib 出错。 + monkeypatch.setattr("nodes.whisper._is_windows", lambda: True) + monkeypatch.setattr(os, "add_dll_directory", lambda d: added.append(d), raising=False) + _load_cuda_libraries() + assert any("cublas" in d and d.endswith("bin") for d in added) +def test_whisper_compute_type_override(tmp_path, monkeypatch) -> None: + """验证请求参数可以覆盖默认计算类型。""" + FakeWhisperModel.instances.clear() + _install_fake_whisper(monkeypatch) + _make_wav(tmp_path / "audio.wav", 5) + response = whisper_invoke( + _whisper_request(tmp_path, params={"language": "ja", "compute_type": "int8"}) + ) + assert response.status == "completed" + _, kwargs = FakeWhisperModel.instances[-1] + assert kwargs["compute_type"] == "int8" + + +def test_whisper_model_raises(tmp_path, monkeypatch) -> None: + """验证模型加载失败时返回 failed 与错误信息。""" + class BrokenModel: + def __init__(self, *args, **kwargs): + raise RuntimeError("model load failed") + + _install_fake_whisper(monkeypatch, BrokenModel) + _make_wav(tmp_path / "audio.wav", 5) + response = whisper_invoke(_whisper_request(tmp_path)) + assert response.status == "failed" + assert "model load failed" in response.error + + +def test_whisper_missing_input(tmp_path) -> None: + """验证缺少 audio_uri 时返回失败。""" + response = whisper_invoke(_whisper_request(tmp_path, inputs={})) + assert response.status == "failed" + + +def test_whisper_missing_file(tmp_path) -> None: + """验证音频文件不存在时返回失败。""" + response = whisper_invoke(_whisper_request(tmp_path)) + assert response.status == "failed" + assert "audio file not found" in response.error + + +# --------------------------------------------------------------------------- +# LLM 节点 +# --------------------------------------------------------------------------- + + +class FakeUrlOpenResponse: + """模拟 urllib 响应对象,提供固定 LLM 译文内容。""" + + def __init__(self, content: str) -> None: + # 预编码为 Chat Completions 风格的 JSON 响应体。 + self._payload = json.dumps( + {"choices": [{"message": {"content": content}}]} + ).encode("utf-8") + + def read(self) -> bytes: + return self._payload + + def __enter__(self): + return self + + def __exit__(self, *args) -> bool: + return False + + +def _make_srt(tmp_path, count=5) -> Path: + """生成标准 SRT 测试文件,文本行为"原文字幕N"。""" + lines = [] + for index in range(count): + lines.extend( + [ + str(index + 1), + f"00:00:{index:02d},000 --> 00:00:{index + 1:02d},000", + f"原文字幕{index + 1}", + "", + ] + ) + path = tmp_path / "in.srt" + path.write_text("\n".join(lines), encoding="utf-8") + return path + + +def test_llm_translate_lines_via_fake_api(monkeypatch) -> None: + """验证通过真实 HTTP 服务器调用 LLM 接口并保持行顺序。""" + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + self.rfile.read(length) + body = json.dumps( + { + "choices": [ + { + "message": { + "content": "译文一\n译文二\n译文三\n译文四\n译文五" + } + } + ] + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args) -> None: + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + monkeypatch.setenv( + "LLM_API_BASE", + f"http://127.0.0.1:{server.server_address[1]}/v1/chat/completions", + ) + monkeypatch.setenv("LLM_API_KEY", "test-key") + result = translate_lines( + ["一", "二", "三", "四", "五"], + {"target_language": "zh-CN"}, + ) + assert result == ["译文一", "译文二", "译文三", "译文四", "译文五"] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_llm_translate_lines_api_error(monkeypatch) -> None: + """验证 LLM 接口不可用时抛出 URLError。""" + def fail_open(request, timeout): + raise urllib.error.URLError("api down") + + monkeypatch.setattr("nodes.llm.urllib.request.urlopen", fail_open) + try: + translate_lines(["一"], {}) + raise AssertionError("expected failure") + except urllib.error.URLError: + pass + + +def test_llm_translate_lines_default_timeout(monkeypatch) -> None: + """验证未配置超时时使用默认 600 秒。""" + captured = {} + + def fake_open(request, timeout): + captured["timeout"] = timeout + return FakeUrlOpenResponse("译文一") + + monkeypatch.setattr("nodes.llm.urllib.request.urlopen", fake_open) + monkeypatch.setenv("LLM_API_BASE", "http://fake/v1/chat/completions") + + result = translate_lines(["一"], {}) + + assert result == ["译文一"] + assert captured["timeout"] == 600 + + +def test_llm_translate_lines_env_timeout(monkeypatch) -> None: + """验证 LLM_TIMEOUT_SECONDS 环境变量可覆盖超时。""" + captured = {} + + def fake_open(request, timeout): + captured["timeout"] = timeout + return FakeUrlOpenResponse("译文一") + + monkeypatch.setattr("nodes.llm.urllib.request.urlopen", fake_open) + monkeypatch.setenv("LLM_API_BASE", "http://fake/v1/chat/completions") + monkeypatch.setenv("LLM_TIMEOUT_SECONDS", "45") + + translate_lines(["一"], {}) + + assert captured["timeout"] == 45 + + +def test_llm_invoke_success(tmp_path, monkeypatch) -> None: + """验证成功调用会把译文回填到 SRT 并输出 cn.srt。""" + source = _make_srt(tmp_path) + + def fake_translate(lines, params): + return [f"译文{i + 1}" for i in range(len(lines))] + + monkeypatch.setattr("nodes.llm.translate_lines", fake_translate) + response = llm_invoke( + InvokeRequest( + run_id="run_1", + node_instance_id="ni_1", + inputs={"srt_uri": str(source)}, + params={"target_language": "zh-CN"}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed" + content = Path(response.outputs["cn_srt_uri"]).read_text(encoding="utf-8") + assert "译文1" in content + + +def test_llm_invoke_pads_short_translation(tmp_path, monkeypatch) -> None: + """验证译文行数不足时用空行补齐,保持 SRT 结构完整。""" + source = _make_srt(tmp_path, count=3) + monkeypatch.setattr( + "nodes.llm.translate_lines", + lambda lines, params: ["only one"], + ) + response = llm_invoke( + InvokeRequest( + run_id="run_2", + node_instance_id="ni_2", + inputs={"srt_uri": str(source)}, + output_dir=str(tmp_path / "out2"), + ) + ) + assert response.status == "completed" + + +def test_llm_invoke_missing_input(tmp_path) -> None: + """验证缺少 srt_uri 时返回失败。""" + response = llm_invoke( + InvokeRequest( + run_id="run_3", + node_instance_id="ni_3", + inputs={}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + + +def test_llm_invoke_missing_file(tmp_path) -> None: + """验证 SRT 文件不存在时返回失败。""" + response = llm_invoke( + InvokeRequest( + run_id="run_4", + node_instance_id="ni_4", + inputs={"srt_uri": str(tmp_path / "missing.srt")}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + + +# --------------------------------------------------------------------------- +# ASS 节点 +# --------------------------------------------------------------------------- + + +SAMPLE_SRT = """ +1 +00:00:01,000 --> 00:00:03,000 +第一行 +第二行 + +2 +00:00:04,000 --> 00:00:06,000 +第三行 +""" + + +def test_ass_parse_and_write(tmp_path) -> None: + """验证多行字幕会被解析并通过左右眼样式写出。""" + entries = parse_srt(SAMPLE_SRT) + assert len(entries) == 2 + assert entries[0][2] == r"第一行\N第二行" + + output = tmp_path / "out.ass" + write_ass(entries, output, "3840x1920") + content = output.read_text(encoding="utf-8") + assert "PlayResX: 3840" in content + assert "PlayResY: 1920" in content + assert "LeftEye" in content + assert "RightEye" in content + assert r"第一行\N第二行" in content + + +def test_ass_parse_malformed(tmp_path) -> None: + """验证畸形 SRT 不会抛出异常且返回空条目或忽略坏行。""" + source = tmp_path / "bad.srt" + source.write_text("1\nnot a time line\n", encoding="utf-8") + assert parse_srt(source.read_text(encoding="utf-8")) == [] + + source.write_text("1", encoding="utf-8") + assert parse_srt(source.read_text(encoding="utf-8")) == [] + + +def test_ass_invoke_success(tmp_path) -> None: + """验证成功调用会按指定分辨率输出 ASS 产物。""" + source = tmp_path / "in.srt" + source.write_text(SAMPLE_SRT, encoding="utf-8") + response = ass_invoke( + InvokeRequest( + run_id="run_1", + node_instance_id="ni_1", + inputs={"cn_srt_uri": str(source)}, + params={"resolution": "1920x1080"}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed" + assert "PlayResX: 1920" in Path(response.outputs["ass_uri"]).read_text(encoding="utf-8") + + +def test_ass_invoke_missing_input(tmp_path) -> None: + """验证缺少 cn_srt_uri 时返回失败。""" + response = ass_invoke( + InvokeRequest( + run_id="run_2", + node_instance_id="ni_2", + inputs={}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + + +def test_ass_invoke_missing_file(tmp_path) -> None: + """验证 SRT 文件不存在时返回失败。""" + response = ass_invoke( + InvokeRequest( + run_id="run_3", + node_instance_id="ni_3", + inputs={"cn_srt_uri": str(tmp_path / "missing.srt")}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + + +def test_resolve_bare_name_found(tmp_path) -> None: + """验证裸模型名会在候选目录父级下按名解析到本地模型。""" + named = tmp_path / "zh-ct2" + named.mkdir() + (named / "model.bin").write_bytes(b"x") + # 候选目录取父目录的兄弟布局:candidates 首项父级即模型根目录。 + candidates = [tmp_path / "models"] + resolved = resolve_model_path({"model_path": "zh-ct2"}, env={}, candidates=candidates) + assert resolved == str(named) + + +def test_resolve_bare_name_missing(tmp_path) -> None: + """验证裸模型名在本地不存在时原样返回,交由 faster-whisper 处理。""" + resolved = resolve_model_path( + {"model_path": "no-such-model"}, env={}, candidates=[tmp_path / "models"] + ) + assert resolved == "no-such-model" + + +def test_whisper_task_translate(tmp_path, monkeypatch) -> None: + """验证 task=translate 参数会传递给 faster-whisper 的 transcribe。""" + class TaskRecorderModel: + def __init__(self, *args, **kwargs): + self.transcribe_kwargs = None + + def transcribe(self, path, **kwargs): + self.transcribe_kwargs = kwargs + return ( + [FakeSegment(0, 1, "中文直出")], + None, + ) + + recorder = TaskRecorderModel() + monkeypatch.setitem(sys.modules, "faster_whisper", types.SimpleNamespace(WhisperModel=lambda *a, **k: recorder)) + _make_wav(tmp_path / "audio.wav", 5) + response = whisper_invoke( + _whisper_request(tmp_path, params={"language": "ja", "task": "translate"}) + ) + assert response.status == "completed" + assert recorder.transcribe_kwargs["task"] == "translate" + assert "中文直出" in Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + + +def test_whisper_condition_on_previous_text(tmp_path, monkeypatch) -> None: + """验证长音频参数 condition_on_previous_text 可配置并默认关闭。""" + captured = {} + + class CondRecorderModel: + def __init__(self, *args, **kwargs): + pass + + def transcribe(self, path, **kwargs): + captured["condition_on_previous_text"] = kwargs.get("condition_on_previous_text") + return ([FakeSegment(0, 1, "ok")], None) + + monkeypatch.setitem( + sys.modules, + "faster_whisper", + types.SimpleNamespace(WhisperModel=lambda *a, **k: CondRecorderModel()), + ) + _make_wav(tmp_path / "audio.wav", 5) + # 默认 False(长音频稳定);显式传 True 可开启。 + whisper_invoke(_whisper_request(tmp_path)) + assert captured["condition_on_previous_text"] is False + whisper_invoke(_whisper_request(tmp_path, params={"condition_on_previous_text": True})) + assert captured["condition_on_previous_text"] is True + + +def test_split_audio_disabled_or_no_ffmpeg(tmp_path, monkeypatch) -> None: + """验证 chunk_seconds<=0 或缺少 ffmpeg 时回退整段,不调用切块。""" + from nodes.whisper import _split_audio + + audio = _make_wav(tmp_path / "in.wav", 5) + called = [] + + def fake_run(*args, **kwargs): + called.append(args) + return subprocess.CompletedProcess([], 0) + + monkeypatch.setattr("nodes.whisper.subprocess.run", fake_run) + # chunk_seconds<=0:直接返回整段,不执行 ffmpeg。 + assert _split_audio(audio, tmp_path, 0, "ffmpeg") == [audio] + # 缺少 ffmpeg:直接返回整段。 + assert _split_audio(audio, tmp_path, 600, None) == [audio] + assert called == [] + + +def test_split_audio_failure_fallback(tmp_path, monkeypatch) -> None: + """验证 ffmpeg 切块失败时回退整段单次转写。""" + from nodes.whisper import _split_audio + + audio = _make_wav(tmp_path / "in.wav", 5) + monkeypatch.setattr( + "nodes.whisper.subprocess.run", + lambda *a, **k: subprocess.CompletedProcess([], 1, stderr="boom"), + ) + assert _split_audio(audio, tmp_path, 600, "ffmpeg") == [audio] + + +def test_split_audio_success_and_empty(tmp_path, monkeypatch) -> None: + """验证切块成功返回块列表;产出为空时回退整段。""" + from nodes.whisper import _split_audio + + audio = _make_wav(tmp_path / "in.wav", 5) + + def fake_run_success(command, **kwargs): + # 模拟 ffmpeg 产出两个真实的块 WAV。 + pattern = command[-1] + for name in ("chunk_000.wav", "chunk_001.wav"): + _make_wav(tmp_path / "chunks" / name, 2) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr("nodes.whisper.subprocess.run", fake_run_success) + chunks = _split_audio(audio, tmp_path, 600, "ffmpeg") + assert len(chunks) == 2 + assert chunks[0].name == "chunk_000.wav" + + # 切块成功但没有产出文件时回退整段。 + monkeypatch.setattr( + "nodes.whisper.subprocess.run", + lambda *a, **k: subprocess.CompletedProcess([], 0), + ) + assert _split_audio(audio, tmp_path / "other", 600, "ffmpeg") == [audio] + + +def test_whisper_chunked_transcription_merges_offsets(tmp_path, monkeypatch) -> None: + """验证分块转写用真实 WAV 块,第二块时间轴按实际时长偏移合并到同一 SRT。""" + from nodes.whisper import _split_audio + + chunk_dir = tmp_path / "out" / "chunks" + chunk_dir.mkdir(parents=True) + # 真实 WAV 块(各 60s),偏移按实际时长累积为 60s。 + chunk1 = _make_wav(chunk_dir / "chunk_000.wav", 60) + chunk2 = _make_wav(chunk_dir / "chunk_001.wav", 60) + monkeypatch.setattr("nodes.whisper._split_audio", lambda a, o, c, f: [chunk1, chunk2]) + FakeWhisperModel.instances.clear() + _install_fake_whisper(monkeypatch) + _make_wav(tmp_path / "audio.wav", 5) + response = whisper_invoke(_whisper_request(tmp_path, params={"chunk_seconds": 60})) + assert response.status == "completed" + content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + # 序号从 1 开始连续递增;第一块无偏移,第二块偏移 60 秒。 + assert content.startswith("1\n") + assert "3\n00:01:00,000" in content + assert "00:00:00,000 --> 00:00:01,000" in content + assert "01:00:00,500 --> 01:00:02,250" in content + assert "00:01:00,000 --> 00:01:01,000" in content + assert "01:01:00,500 --> 01:01:02,250" in content + + +def test_whisper_chunk_disabled_single_call(tmp_path, monkeypatch) -> None: + """验证 chunk_seconds=0 时单次调用、不切块。""" + FakeWhisperModel.instances.clear() + _install_fake_whisper(monkeypatch) + _make_wav(tmp_path / "audio.wav", 5) + response = whisper_invoke(_whisper_request(tmp_path, params={"chunk_seconds": 0})) + assert response.status == "completed" + # 单次调用:init 只记录一次实例。 + assert len(FakeWhisperModel.instances) == 1 + + +def test_whisper_vad_filter_default_off(tmp_path, monkeypatch) -> None: + """验证 vad_filter 默认开启,可显式关闭。""" + captured = {} + + class VadRecorderModel: + def __init__(self, *args, **kwargs): + pass + + def transcribe(self, path, **kwargs): + captured["vad_filter"] = kwargs.get("vad_filter") + return ([FakeSegment(0, 1, "ok")], None) + + monkeypatch.setitem( + sys.modules, + "faster_whisper", + types.SimpleNamespace(WhisperModel=lambda *a, **k: VadRecorderModel()), + ) + _make_wav(tmp_path / "audio.wav", 5) + whisper_invoke(_whisper_request(tmp_path)) + assert captured["vad_filter"] is True + whisper_invoke(_whisper_request(tmp_path, params={"vad_filter": False})) + assert captured["vad_filter"] is False + + +def _make_wav(path, seconds, rate=16000) -> Path: + """生成指定时长的 16kHz 单声道 16bit 静音 WAV。""" + import wave + + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(rate) + wav.writeframes(b"\x00\x00" * int(rate * seconds)) + return path + + +def test_whisper_chunk_offset_uses_actual_duration(tmp_path, monkeypatch) -> None: + """验证分块偏移按 WAV 实际时长累积(1s+2s 块 → 第二块偏移 1s 而非块长 60s)。""" + chunk_dir = tmp_path / "out" / "chunks" + chunk_dir.mkdir(parents=True) + chunk1 = _make_wav(chunk_dir / "chunk_000.wav", 1) + chunk2 = _make_wav(chunk_dir / "chunk_001.wav", 2) + monkeypatch.setattr("nodes.whisper._split_audio", lambda a, o, c, f: [chunk1, chunk2]) + FakeWhisperModel.instances.clear() + _install_fake_whisper(monkeypatch) + _make_wav(tmp_path / "audio.wav", 5) + response = whisper_invoke(_whisper_request(tmp_path, params={"chunk_seconds": 60})) + assert response.status == "completed" + content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + # 第二块偏移 = 第一块实际时长 1s(若用块长假设则会是 60s → 00:01:00)。 + assert "00:00:01,000 --> 00:00:02,000" in content + assert "00:01:00,000 --> 00:01:01,000" not in content + + +def test_whisper_segment_logs_full_video_time(caplog, tmp_path, monkeypatch) -> None: + """验证每条分段日志包含编号与完整视频角度(含分块偏移)的时间范围。""" + chunk_dir = tmp_path / "out" / "chunks" + chunk_dir.mkdir(parents=True) + chunk1 = _make_wav(chunk_dir / "chunk_000.wav", 60) + chunk2 = _make_wav(chunk_dir / "chunk_001.wav", 60) + monkeypatch.setattr("nodes.whisper._split_audio", lambda a, o, c, f: [chunk1, chunk2]) + FakeWhisperModel.instances.clear() + _install_fake_whisper(monkeypatch) + _make_wav(tmp_path / "audio.wav", 5) + with caplog.at_level("INFO", logger="vrsub.whisper"): + response = whisper_invoke(_whisper_request(tmp_path, params={"chunk_seconds": 60})) + assert response.status == "completed" + seg_logs = [r.message for r in caplog.records if r.message.startswith("分段 #")] + # 第一块:0s 起;第二块:偏移 60s(第一块实际时长)。 + assert any("分段 #1: 00:00:00,000 --> 00:00:01,000" in m for m in seg_logs) + assert any(m.startswith("分段 #3: 00:01:00,000") for m in seg_logs) + + +def test_wav_duration_fallback_on_invalid_file(tmp_path) -> None: + """验证读取时长时,损坏/缺失文件回退 fallback 值(真实非法文件,非占位字节)。""" + from nodes.whisper import _wav_duration_seconds + + bad = tmp_path / "bad.wav" + bad.write_text("this is not a wav file", encoding="utf-8") + assert _wav_duration_seconds(bad, 60.0) == 60.0 + missing = tmp_path / "missing.wav" + assert _wav_duration_seconds(missing, 60.0) == 60.0 + + +# --------------------------------------------------------------------------- +# VLM OCR 节点(直接请求 Ollama /api/generate,流式) +# --------------------------------------------------------------------------- + + +# 真实的最小 PNG(1x1 像素,合法文件),用于构造真实图片输入。 +_MIN_PNG_BASE64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + + +class FakeChatResponse: + """模拟 Ollama /api/generate 流式响应:readline() 逐行返回 JSON 块。 + + done=False(默认)时仅一行内容,随后 readline 返回空表示流结束; + done=True 时末尾追加一行带 done 标记的结束块。 + """ + + def __init__(self, content: str, done: bool = False) -> None: + self._lines = [ + json.dumps({"message": {"role": "assistant", "content": content}}).encode() + ] + if done: + self._lines.append( + json.dumps({"message": {"role": "assistant", "content": ""}, "done": True}).encode() + ) + + def readline(self) -> bytes: + return self._lines.pop(0) if self._lines else b"" + + def __enter__(self): + return self + + def __exit__(self, *args) -> bool: + return False + + +def _real_png(tmp_path) -> Path: + """生成真实 PNG 图片文件(解码自合法 base64)。""" + image = tmp_path / "frame.png" + image.write_bytes(base64.b64decode(_MIN_PNG_BASE64)) + return image + + +def test_vlm_success(tmp_path, monkeypatch) -> None: + """验证真实图片经流式读取后输出清洗过的文字与 ocr.txt 产物。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + captured = {} + + def fake_urlopen(request, timeout): + captured["timeout"] = timeout + captured["url"] = request.full_url + return FakeChatResponse("HELLO WORLD 123\n```markdown\n```\n```\n") + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", fake_urlopen) + response = vlm_invoke( + InvokeRequest( + run_id="run_1", + node_instance_id="", + inputs={"image_uri": str(image)}, + params={"model": "glm-ocr:latest"}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + assert response.outputs["text"] == "HELLO WORLD 123" + assert captured["url"].endswith("/api/chat") + assert captured["timeout"] == 5 + content = Path(response.outputs["text_uri"]).read_text(encoding="utf-8") + assert "HELLO WORLD 123" in content + + +def test_vlm_missing_input_and_file(tmp_path) -> None: + """验证缺少 image_uri 或图片不存在时返回失败。""" + from nodes.vlm import invoke as vlm_invoke + + assert vlm_invoke( + InvokeRequest(run_id="r", node_instance_id="", inputs={}, output_dir=str(tmp_path)) + ).status == "failed" + assert vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(tmp_path / "missing.png")}, + output_dir=str(tmp_path), + ) + ).status == "failed" + + +def test_vlm_network_error(tmp_path, monkeypatch) -> None: + """验证 Ollama 服务不可达时返回 failed。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + + def fail_open(request, timeout): + raise urllib.error.URLError("ollama down") + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", fail_open) + response = vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + assert "ollama down" in response.error + + +def test_vlm_response_format_error(tmp_path, monkeypatch) -> None: + """验证流式块既无 response 也无 done 标记时(格式错误)返回 failed。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + + class BadResponse: + def readline(self) -> bytes: + return b'{"foo": 1}' + + def __enter__(self): + return self + + def __exit__(self, *args) -> bool: + return False + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", lambda *a, **k: BadResponse()) + response = vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + + +def test_vlm_clean_ocr_text() -> None: + """验证清洗逻辑会剔除 markdown 围栏与空行。""" + from nodes.vlm import _clean_ocr_text + + cleaned = _clean_ocr_text("第一行\n```markdown\n```\n\n第二行\n```") + assert cleaned == "第一行\n第二行" + + +def test_vlm_options_in_body(tmp_path, monkeypatch) -> None: + """验证请求体携带采样选项(temperature=0/repeat_penalty/num_predict),可参数覆盖。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + captured = {} + + def fake_urlopen(request, timeout): + import json as _json + + captured["body"] = _json.loads(request.data.decode("utf-8")) + return FakeChatResponse("SUB 001") + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", fake_urlopen) + vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + params={"repeat_penalty": 1.3, "num_predict": 128}, + output_dir=str(tmp_path), + ) + ) + assert captured["body"]["options"] == { + "temperature": 0.3, + "repeat_penalty": 1.3, + "num_predict": 128, + } + + +def test_vlm_call_structure_system_prompt_and_stop(tmp_path, monkeypatch) -> None: + """验证 /api/chat 调用结构:system 承载指令、user 只携带图片、stop=。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + captured = {} + + def fake_urlopen(request, timeout): + import json as _json + + captured["body"] = _json.loads(request.data.decode("utf-8")) + return FakeChatResponse("SUB 001") + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", fake_urlopen) + vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + params={"prompt": "识别图片中的所有文字,原样输出。"}, + output_dir=str(tmp_path), + ) + ) + body = captured["body"] + assert body["stop"] == ["\n", "\n答", "答"] + assert body["stream"] is True + assert body["messages"][0]["role"] == "system" + assert "识别图片" in body["messages"][0]["content"] + assert body["messages"][1]["role"] == "user" + assert body["messages"][1]["content"] == "" + assert len(body["messages"][1]["images"]) == 1 +def test_vlm_stream_stop_sequence_truncation(tmp_path, monkeypatch) -> None: + """验证流式读取命中终止序列(换行+答,\n答)即停止并截断该标记。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + + def fake_urlopen(request, timeout): + # 模型输出先给出真实文本,随后进入“答:”式重复循环并输出终止序列。 + return FakeChatResponse("SUB 001\n答:重复循环垃圾") + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", fake_urlopen) + response = vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "completed", response.error + # 终止序列及其后内容必须被截掉,只保留终止前的真实识别文本。 + assert response.outputs["text"] == "SUB 001" + + +def test_vlm_stream_done_with_message(tmp_path, monkeypatch) -> None: + """验证带 done 标记的流式结束块触发停止,结果正常返回。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + + def fake_urlopen(request, timeout): + return FakeChatResponse("SUB 001", done=True) + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", fake_urlopen) + response = vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "completed", response.error + assert response.outputs["text"] == "SUB 001" + + +def test_vlm_stream_done_only_end(tmp_path, monkeypatch) -> None: + """验证仅含 done 标记(无 response)的行视为流结束,空结果正常完成。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + + class DoneOnlyResponse: + def readline(self) -> bytes: + if not self._consumed: + self._consumed = True + return b'{"done": true}' + return b"" + + def __enter__(self): + self._consumed = False + return self + + def __exit__(self, *args) -> bool: + return False + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", lambda *a, **k: DoneOnlyResponse()) + response = vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "completed", response.error + assert response.outputs["text"] == "" + + +def test_vlm_stream_deadline_timeout(tmp_path, monkeypatch) -> None: + """验证整体 5 秒截止:流式读取超过 deadline 立即终止并返回 failed。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + # 第一次调用计算 deadline(100+5=105),第二次调用已越过截止(200>=105)。 + monotonic_values = iter([100.0, 200.0]) + monkeypatch.setattr("nodes.vlm.time.monotonic", lambda: next(monotonic_values)) + + def fake_urlopen(request, timeout): + return FakeChatResponse("SUB 001") + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", fake_urlopen) + response = vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + params={"timeout_seconds": 5}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + assert "timed out" in response.error + + +def test_vlm_extract_gettext() -> None: + """验证从模型输出中提取 标签内容的各种形态。""" + from nodes.vlm import _extract_gettext + + # 正常:标签包裹的内容被提取。 + assert _extract_gettext("还有没有什么困扰 或者奇怪的地方吗") == ( + "还有没有什么困扰 或者奇怪的地方吗" + ) + # 空标签:返回空字符串。 + assert _extract_gettext("前缀后缀") == "" + # 多个标签(重复循环):只取第一个。 + assert _extract_gettext("SUB 001SUB 001") == "SUB 001" + # 跨行内容:DOTALL 让 . 匹配换行。 + assert _extract_gettext("第一行\n第二行") == "第一行\n第二行" + # 未按格式输出(无标签):回退原始文本,保持旧行为。 + assert _extract_gettext("没有标签的裸文本") == "没有标签的裸文本" + + +def test_vlm_gettext_in_invoke(tmp_path, monkeypatch) -> None: + """验证整条调用链:模型返回 包裹内容时,产物只含标签内文本。""" + from nodes.vlm import invoke as vlm_invoke + + image = _real_png(tmp_path) + + def fake_urlopen(request, timeout): + return FakeChatResponse("SUB 001 围栏垃圾```") + + monkeypatch.setattr("nodes.vlm.urllib.request.urlopen", fake_urlopen) + response = vlm_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"image_uri": str(image)}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "completed", response.error + assert response.outputs["text"] == "SUB 001" + +def test_vlm_truncate_at_stop() -> None: + """验证多个终止序列的截断:取最先命中位置,未命中原样返回。""" + from nodes.vlm import _truncate_at_stop + + # 命中 "\n答"(位置更靠前)。 + assert _truncate_at_stop("SUB 001\n答:重复") == "SUB 001" + # 未命中 "\n答" 但命中单个 "答"。 + assert _truncate_at_stop("SUB 001 答") == "SUB 001 " + # 多个序列均命中:取最早出现的位置("答" 在 "答:" 之前)。 + assert _truncate_at_stop("SUB 001答\n答:循环") == "SUB 001" + # 未命中任何序列:原样返回。 + assert _truncate_at_stop("还有没有什么困扰 或者奇怪的地方吗") == ( + "还有没有什么困扰 或者奇怪的地方吗" + ) diff --git a/tests/test_ocr_flow.py b/tests/test_ocr_flow.py new file mode 100644 index 0000000..b8b0bbb --- /dev/null +++ b/tests/test_ocr_flow.py @@ -0,0 +1,401 @@ +"""抽帧与字幕 OCR 节点单元测试。 + +frame-extract 用 testdata 真实视频抽帧+裁切+720p 压缩;subtitle-ocr 的 OCR +网络调用(vlm-ocr)按 I/O 边界 mock,但喂给它的帧图片是真实的 testdata 资产。 +应用层不再加工模型输出文本,只做长度上限校验(超长报错跳过)。 +""" + +import json +from pathlib import Path + +from wov_sdk.models import InvokeRequest, InvokeResponse + +from nodes.frame_extract import invoke as frame_invoke +from nodes.subtitle_ocr import _assemble_srt +from nodes.subtitle_ocr import invoke as ocr_invoke + +# 单体根目录:tests/ 的上一级。 +WORKSPACE = Path(__file__).resolve().parent.parent +TESTDATA = WORKSPACE / "testdata" +# 10s 测试视频:SUB 001 在 1-4s、SUB 002 在 6-9s。 +VIDEO = TESTDATA / "subtitle_10s.mp4" +TEXT_IMG = TESTDATA / "ocr_text.png" + + +def _png_size(path: Path) -> tuple[int, int]: + """从 PNG 头读取宽高(真实图片尺寸断言)。""" + data = path.read_bytes() + assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a real png" + width = int.from_bytes(data[16:20], "big") + height = int.from_bytes(data[20:24], "big") + return width, height + + +def _frame_request(tmp_path, video=VIDEO, **params) -> InvokeRequest: + """构造 frame-extract 调用请求。""" + return InvokeRequest( + run_id="run_fx", + node_instance_id="", + inputs={"video_uri": str(video)}, + params=params, + output_dir=str(tmp_path / "out"), + ) + + +# --------------------------------------------------------------------------- +# frame-extract:抽帧 + 裁切 + 720p 压缩 +# --------------------------------------------------------------------------- + + +def test_frame_extract_crop_and_manifest(tmp_path) -> None: + """真实视频抽帧:裁切下半 50% 后帧尺寸为 1280x360,清单时间轴正确。""" + response = frame_invoke( + _frame_request(tmp_path, interval_seconds=1, crop=[0, 0.5, 1, 0.5]) + ) + assert response.status == "completed", response.error + manifest = json.loads(Path(response.outputs["frames_manifest"]).read_text(encoding="utf-8")) + assert len(manifest) >= 9 + assert [round(item["time"], 3) for item in manifest] == [ + round(i * 1.0, 3) for i in range(len(manifest)) + ] + first = Path(manifest[0]["image_uri"]) + assert first.is_file() + # 1280x360 已在 720p 内,压缩不改变尺寸。 + assert _png_size(first) == (1280, 360) + + +def test_frame_extract_default_params(tmp_path) -> None: + """未指定参数时使用默认值:抽帧间隔 0.5 秒 + 默认底部裁切区域。""" + response = frame_invoke(_frame_request(tmp_path)) + assert response.status == "completed", response.error + manifest = json.loads(Path(response.outputs["frames_manifest"]).read_text(encoding="utf-8")) + assert manifest + # 默认间隔 0.5s:25fps 下 step=round(12.5)=12(银行家舍入), + # 帧时间按 step/fps=12/25=0.48s 步进(帧号精确,采样周期由帧量化决定)。 + assert [round(item["time"], 3) for item in manifest] == [ + round(i * 12 / 25, 3) for i in range(len(manifest)) + ] + +def test_frame_extract_missing_video(tmp_path) -> None: + """缺少 video_uri 时返回失败。""" + response = frame_invoke( + InvokeRequest( + run_id="r", node_instance_id="", inputs={}, output_dir=str(tmp_path) + ) + ) + assert response.status == "failed" + + +def test_frame_extract_bad_crop(tmp_path) -> None: + """crop 比例越界(超出画面)时返回失败。""" + assert frame_invoke(_frame_request(tmp_path, crop=[0, 0.5, 1, 1.5])).status == "failed" + assert frame_invoke(_frame_request(tmp_path, crop=[-0.1, 0, 1, 0.5])).status == "failed" + assert frame_invoke(_frame_request(tmp_path, crop="abc")).status == "failed" + assert frame_invoke(_frame_request(tmp_path, crop=[0, 0.5, 1])).status == "failed" + # 各值域合法但 x+w 越出画面。 + assert frame_invoke(_frame_request(tmp_path, crop=[0.6, 0, 0.5, 0.3])).status == "failed" + + +def test_frame_extract_video_missing_file(tmp_path) -> None: + """video_uri 指向不存在的文件时返回失败。""" + response = frame_invoke(_frame_request(tmp_path, video=tmp_path / "none.mp4")) + assert response.status == "failed" + assert "not found" in response.error + + +def test_frame_extract_bad_interval(tmp_path) -> None: + """间隔 <= 0 时返回失败。""" + response = frame_invoke(_frame_request(tmp_path, interval_seconds=0)) + assert response.status == "failed" + + +def test_frame_extract_ffmpeg_fails(monkeypatch, tmp_path) -> None: + """ffmpeg 抽帧失败时透传错误。""" + import subprocess as sp + + monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720)) + monkeypatch.setattr("nodes.frame_extract._video_fps", lambda *a, **k: 25.0) + monkeypatch.setattr("nodes.frame_extract._video_duration", lambda *a, **k: 10.0) + monkeypatch.setattr( + "nodes.frame_extract.subprocess.run", + lambda *a, **k: sp.CompletedProcess([], 1, stderr="boom"), + ) + response = frame_invoke(_frame_request(tmp_path)) + assert response.status == "failed" + assert "boom" in response.error + + +def test_video_size_unreadable(monkeypatch) -> None: + """ffmpeg -i 输出不含视频流信息时返回 None。""" + import subprocess as sp + + from nodes.frame_extract import _video_size + + monkeypatch.setattr( + "nodes.frame_extract.subprocess.run", + lambda *a, **k: sp.CompletedProcess([], 0, stderr="no video stream"), + ) + assert _video_size(Path("/tmp/x.mp4"), "ffmpeg") is None + + +def test_frame_extract_video_size_unknown(monkeypatch, tmp_path) -> None: + """无法读取视频分辨率时返回失败。""" + monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: None) + response = frame_invoke(_frame_request(tmp_path)) + assert response.status == "failed" + assert "video size" in response.error + + +def test_video_duration_unreadable(monkeypatch) -> None: + """ffmpeg -i 输出缺少 Duration 时返回 None。""" + import subprocess as sp + + from nodes.frame_extract import _video_duration + + monkeypatch.setattr( + "nodes.frame_extract.subprocess.run", + lambda *a, **k: sp.CompletedProcess([], 0, stderr="no duration info"), + ) + assert _video_duration(Path("/tmp/x.mp4"), "ffmpeg") is None + + +def test_frame_extract_duration_unknown(monkeypatch, tmp_path) -> None: + """无法读取视频时长时返回失败。""" + monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720)) + monkeypatch.setattr("nodes.frame_extract._video_duration", lambda *a, **k: None) + response = frame_invoke(_frame_request(tmp_path)) + assert response.status == "failed" + assert "duration" in response.error + + +def test_frame_extract_fps_unknown(monkeypatch, tmp_path) -> None: + """无法读取视频帧率时返回失败。""" + monkeypatch.setattr("nodes.frame_extract._video_size", lambda *a, **k: (1280, 720)) + monkeypatch.setattr("nodes.frame_extract._video_duration", lambda *a, **k: 10.0) + monkeypatch.setattr("nodes.frame_extract._video_fps", lambda *a, **k: None) + response = frame_invoke(_frame_request(tmp_path)) + assert response.status == "failed" + assert "fps" in response.error + +def test_frame_step_conversion() -> None: + """帧间隔换算:step=round(间隔秒×fps),至少为 1。""" + from nodes.frame_extract import _frame_step + + assert _frame_step(fps=25.0, interval=0.2) == 5 + assert _frame_step(fps=25.0, interval=1.0) == 25 + assert _frame_step(fps=29.97, interval=1.0) == 30 + # fps 很低时 step 也不会小于 1(每帧都取)。 + assert _frame_step(fps=1.0, interval=0.2) == 1 + + +def test_video_fps_parse(monkeypatch) -> None: + """帧率解析:支持小数(29.97)与有理数(30000/1001)。""" + import subprocess as sp + + from nodes.frame_extract import _video_fps + + monkeypatch.setattr( + "nodes.frame_extract.subprocess.run", + lambda *a, **k: sp.CompletedProcess( + [], 0, stderr="Stream #0:0: Video: h264, 1280x720, 30000/1001 fps, 30000/1001 tbr" + ), + ) + assert _video_fps(Path("/tmp/x.mp4"), "ffmpeg") == 30000 / 1001 + monkeypatch.setattr( + "nodes.frame_extract.subprocess.run", + lambda *a, **k: sp.CompletedProcess( + [], 0, stderr="Stream #0:0: Video: h264, 1280x720, 25 fps, 25 tbr" + ), + ) + assert _video_fps(Path("/tmp/x.mp4"), "ffmpeg") == 25.0 + monkeypatch.setattr( + "nodes.frame_extract.subprocess.run", + lambda *a, **k: sp.CompletedProcess([], 0, stderr="no video stream"), + ) + assert _video_fps(Path("/tmp/x.mp4"), "ffmpeg") is None + +def test_frame_extract_one_second_exact_frames(tmp_path) -> None: + """真实视频 1s 间隔按秒 seek 精确抽帧:10s 视频应得 10 帧,时间 0..9。""" + response = frame_invoke( + _frame_request(tmp_path, interval_seconds=1, crop=[0, 0.7, 1, 0.3]) + ) + assert response.status == "completed", response.error + manifest = json.loads(Path(response.outputs["frames_manifest"]).read_text(encoding="utf-8")) + assert [round(item["time"], 3) for item in manifest] == [ + round(i * 1.0, 3) for i in range(len(manifest)) + ] + assert len(manifest) == 10 + + +# --------------------------------------------------------------------------- +# subtitle-ocr:OCR 循环 + 长度上限 + 合并 + SRT 组装 +# --------------------------------------------------------------------------- + + +def _frames_manifest(tmp_path, frame_specs) -> Path: + """构造真实 frames.json;frame_specs=[(time, image_path), ...]。""" + items = [{"time": time, "image_uri": str(image)} for time, image in frame_specs] + path = tmp_path / "frames.json" + path.write_text(json.dumps(items), encoding="utf-8") + return path + + +def test_assemble_srt_real_timeline() -> None: + """SRT 组装:起始=帧时间,结束=最后可见帧时间+采样间隔。""" + lines = _assemble_srt([(0.0, 6.0, "A"), (6.0, 8.0, "B")], interval_seconds=2.0) + text = "\n".join(lines) + assert text.startswith("1\n") + # A 最后可见帧 6.0 + 间隔 2.0 = 8.0(而非下一条字幕的出现时间)。 + assert "00:00:00,000 --> 00:00:08,000" in text + assert "00:00:06,000 --> 00:00:10,000" in text + + +def test_sampling_interval_from_manifest() -> None: + """采样间隔从帧清单时间轴推导:均匀间隔取相邻差,退化清单回退默认值。""" + from nodes.subtitle_ocr import _sampling_interval + + manifest = [{"time": i * 0.2, "image_uri": f"f{i}.png"} for i in range(10)] + assert _sampling_interval(manifest, 2.0) == 0.2 + # 单帧(无法算差)与异常时间序:回退默认值。 + assert _sampling_interval([{"time": 0.0, "image_uri": "f0.png"}], 2.0) == 2.0 + assert _sampling_interval( + [{"time": 0.0}, {"time": 0.0}, {"time": 0.2}], 2.0 + ) == 0.2 + +def test_ocr_merges_consecutive_same_text(monkeypatch, tmp_path) -> None: + """连续帧相同字幕合并为一条;消失时间=最后可见帧+间隔,空白段保留。""" + # SUB 001 在 0/2s,4s 为空帧,SUB 002 在 6/8s。 + frame_texts = [ + (0.0, "SUB 001"), (2.0, "SUB 001"), (4.0, ""), + (6.0, "SUB 002"), (8.0, "SUB 002"), + ] + frames = [] + for index, (time, _text) in enumerate(frame_texts): + image = tmp_path / f"f{index}.png" + image.write_bytes(TEXT_IMG.read_bytes()) + frames.append((time, image)) + mapping = {str(image): text for (time, image), (_, text) in zip(frames, frame_texts)} + + def fake_vlm(node_id, request): + return InvokeResponse(status="completed", outputs={"text": mapping[request.inputs["image_uri"]]}) + + monkeypatch.setattr("wov_app.registry.invoke", fake_vlm) + manifest = _frames_manifest(tmp_path, frames) + response = ocr_invoke( + InvokeRequest( + run_id="run_ocr", + node_instance_id="", + inputs={"frames_manifest": str(manifest)}, + params={}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + assert srt.count("SUB 001") == 1 + assert srt.count("SUB 002") == 1 + # SUB 001 最后可见帧 2.0 + 间隔 2.0 = 4.0 消失(而非拖到 SUB 002 出现)。 + assert "00:00:00,000 --> 00:00:04,000" in srt + assert "00:00:06,000 --> 00:00:10,000" in srt + + +def test_ocr_skips_failed_frames(monkeypatch, tmp_path) -> None: + """个别帧 OCR 失败时跳过,不影响其余帧汇总。""" + frames = [] + for index in range(3): + image = tmp_path / f"f{index}.png" + image.write_bytes(TEXT_IMG.read_bytes()) + frames.append((index * 2.0, image)) + + def fake_vlm(node_id, request): + if "f1" in request.inputs["image_uri"]: + return InvokeResponse(status="failed", error="boom") + return InvokeResponse(status="completed", outputs={"text": "SUB 001"}) + + monkeypatch.setattr("wov_app.registry.invoke", fake_vlm) + manifest = _frames_manifest(tmp_path, frames) + response = ocr_invoke( + InvokeRequest( + run_id="run_ocr", + node_instance_id="", + inputs={"frames_manifest": str(manifest)}, + params={}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + assert "SUB 001" in srt + + +def test_ocr_missing_manifest(tmp_path) -> None: + """缺少 frames_manifest 或清单文件不存在时返回失败。""" + response = ocr_invoke( + InvokeRequest(run_id="r", node_instance_id="", inputs={}, output_dir=str(tmp_path)) + ) + assert response.status == "failed" + response = ocr_invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"frames_manifest": str(tmp_path / "none.json")}, + output_dir=str(tmp_path), + ) + ) + assert response.status == "failed" + + +def test_ocr_skips_oversized_output(monkeypatch, tmp_path) -> None: + """超长输出(模型重复循环等)直接报错跳过该帧,不进入 SRT。""" + frames = [] + for index in range(3): + image = tmp_path / f"o{index}.png" + image.write_bytes(TEXT_IMG.read_bytes()) + frames.append((index * 2.0, image)) + + def fake_vlm(node_id, request): + if "o0" in request.inputs["image_uri"]: + return InvokeResponse(status="completed", outputs={"text": "重复字幕\n" * 50}) + if "o1" in request.inputs["image_uri"]: + # 空输出帧跳过。 + return InvokeResponse(status="completed", outputs={"text": ""}) + return InvokeResponse(status="completed", outputs={"text": "SUB 001"}) + + monkeypatch.setattr("wov_app.registry.invoke", fake_vlm) + manifest = _frames_manifest(tmp_path, frames) + response = ocr_invoke( + InvokeRequest( + run_id="run_ocr", + node_instance_id="", + inputs={"frames_manifest": str(manifest)}, + params={"max_result_chars": 200}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + assert "SUB 001" in srt + assert "重复字幕" not in srt + + +def test_ocr_passes_short_text_through(monkeypatch, tmp_path) -> None: + """不超过上限的模型输出原样进入 SRT(不再做应用层过滤)。""" + image = tmp_path / "s0.png" + image.write_bytes(TEXT_IMG.read_bytes()) + frames = [(0.0, image)] + + def fake_vlm(node_id, request): + return InvokeResponse(status="completed", outputs={"text": " SUB 001 "}) + + monkeypatch.setattr("wov_app.registry.invoke", fake_vlm) + manifest = _frames_manifest(tmp_path, frames) + response = ocr_invoke( + InvokeRequest( + run_id="run_ocr", node_instance_id="", + inputs={"frames_manifest": str(manifest)}, + params={}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + srt = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + assert "SUB 001" in srt diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..7512418 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,116 @@ +"""进程内节点注册表测试。 + +覆盖节点注册、全量注册、查询、进程内调用以及未注册节点的报错路径, +验证注册表作为调度器唯一调用入口的正确性。 +""" + +import pytest + +from wov_app import registry +from wov_sdk.models import InvokeRequest, InvokeResponse, NodeManifest + + +def _echo_manifest() -> NodeManifest: + """构造最小合法 echo 节点清单。""" + return NodeManifest( + id="echo", + name="Echo", + version="1.0.0", + capability="echo", + command=["python", "-m", "echo"], + repo_dir="nodes", + ) + + +def test_register_and_list() -> None: + """验证注册后可查询与列出节点,且按 ID 排序。""" + registry.register(_echo_manifest(), lambda request: InvokeResponse(status="completed")) + registry.register( + NodeManifest( + id="z-node", + name="Z", + version="1", + capability="x", + command=["python", "-m", "z"], + repo_dir="nodes", + ), + lambda request: InvokeResponse(status="completed"), + ) + assert [node.id for node in registry.list_nodes()] == ["echo", "z-node"] + assert registry.get_node("echo").capability == "echo" + assert registry.get_node("missing") is None + + +def test_register_validation() -> None: + """验证非法 manifest 注册会被协议校验拒绝。""" + invalid = _echo_manifest() + invalid.id = "" + with pytest.raises(ValueError): + registry.register(invalid, lambda request: InvokeResponse(status="completed")) + + +def test_register_all_loads_builtin_nodes() -> None: + """验证 register_all 会加载 manifests/ 下全部内置节点。""" + registry.register_all() + ids = {node.id for node in registry.list_nodes()} + assert { + "echo", + "ffmpeg-extract", + "faster-whisper", + "llm-translate", + "vlm-ocr", + "srt-to-dual-eye-ass", + } <= ids + + +def test_invoke_calls_handler() -> None: + """验证 invoke 会把请求转发给注册的进程内处理器。""" + captured = {} + + def handler(request: InvokeRequest) -> InvokeResponse: + captured["run_id"] = request.run_id + return InvokeResponse(status="completed", outputs={"text": "ok"}) + + registry.register(_echo_manifest(), handler) + response = registry.invoke("echo", InvokeRequest(run_id="run_1", node_instance_id="")) + assert response.status == "completed" + assert response.outputs == {"text": "ok"} + assert captured["run_id"] == "run_1" + + +def test_invoke_unknown_node() -> None: + """验证调用未注册节点时抛出 ValueError。""" + with pytest.raises(ValueError, match="not registered"): + registry.invoke("missing", InvokeRequest(run_id="run_1", node_instance_id="")) + + +def test_invoke_logs_node_lifecycle(caplog) -> None: + """验证 invoke 会记录节点的开始/完成/耗时日志(主进程可见)。""" + registry.register( + _echo_manifest(), lambda request: InvokeResponse(status="completed", outputs={"text": "ok"}) + ) + with caplog.at_level("INFO", logger="vrsub.node"): + registry.invoke("echo", InvokeRequest(run_id="run_1", node_instance_id="")) + assert any("节点 echo 开始" in record.message for record in caplog.records) + assert any("节点 echo 完成" in record.message for record in caplog.records) + + +def test_invoke_logs_node_failure(caplog) -> None: + """验证节点返回 failed 时记录失败日志。""" + registry.register( + _echo_manifest(), lambda request: InvokeResponse(status="failed", error="boom") + ) + with caplog.at_level("INFO", logger="vrsub.node"): + registry.invoke("echo", InvokeRequest(run_id="run_1", node_instance_id="")) + assert any("节点 echo 失败" in record.message for record in caplog.records) + + +def test_get_logger_idempotent() -> None: + """验证日志器重复获取不会重复附加控制台处理器。""" + from wov_app.logging import get_logger + + logger = get_logger("idempotent") + handler_count = len(logger.handlers) + again = get_logger("idempotent") + assert again is logger + assert len(again.handlers) == handler_count diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..e52fac8 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,655 @@ +"""调度器单元测试。 + +覆盖拓扑排序、任务执行成功/失败分支、输入引用解析、MIME 推断以及 +后台轮询线程的启动与停止。节点调用改为进程内注册表直接调用。 +""" + +import time +from pathlib import Path + +import pytest + +from wov_app import registry +from wov_app.db import Database +from wov_app.scheduler import WorkflowScheduler, topological_sort +from wov_sdk.models import ( + InvokeResponse, + NodeManifest, + WorkflowDefinition, + WorkflowEdge, + WorkflowNode, +) + +# 单体根目录:tests/ 的上一级。 +WORKSPACE = Path(__file__).resolve().parent.parent + + +def _register_echo() -> None: + """把内置 echo 节点注册到进程内注册表。""" + from nodes.echo import invoke + + registry.register(NodeManifest.load(str(WORKSPACE / "manifests" / "echo.json")), invoke) + + +def _db(tmp_path) -> Database: + """在临时目录创建独立数据库。""" + return Database(tmp_path / "wov.db") + + +def _echo_definition() -> WorkflowDefinition: + """构造引用 Echo 节点的单步骤工作流定义。""" + return WorkflowDefinition( + name="echo-flow", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="echo", + inputs={"file_uri": "input.video_uri"}, + ) + ], + edges=[], + entry_inputs={"video_uri": "file"}, + final_outputs={"result": "step.file_uri"}, + ) + + +def test_topological_sort() -> None: + """验证 DAG 排序保持依赖顺序,并拒绝环与未知边。""" + definition = WorkflowDefinition( + name="dag", + version=1, + nodes=[ + WorkflowNode(id="a", node_type="x"), + WorkflowNode(id="b", node_type="x"), + WorkflowNode(id="c", node_type="x"), + ], + edges=[ + WorkflowEdge(from_node="a", to_node="b"), + WorkflowEdge(from_node="a", to_node="c"), + ], + ) + order = topological_sort(definition) + assert order.index("a") < order.index("b") + assert order.index("a") < order.index("c") + + cycle = WorkflowDefinition( + name="cycle", + version=1, + nodes=[ + WorkflowNode(id="a", node_type="x"), + WorkflowNode(id="b", node_type="x"), + ], + edges=[ + WorkflowEdge(from_node="a", to_node="b"), + WorkflowEdge(from_node="b", to_node="a"), + ], + ) + with pytest.raises(ValueError, match="cycle"): + topological_sort(cycle) + + with pytest.raises(ValueError, match="unknown edge"): + topological_sort( + WorkflowDefinition( + name="bad", + version=1, + nodes=[WorkflowNode(id="a", node_type="x")], + edges=[WorkflowEdge(from_node="a", to_node="missing")], + ) + ) + + +def test_execute_echo_workflow(tmp_path) -> None: + """验证排队任务可被完整执行并登记全部产物。""" + db = _db(tmp_path) + input_file = tmp_path / "input.txt" + input_file.write_text("hello scheduler", encoding="utf-8") + _register_echo() + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, _echo_definition().to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_1", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(input_file), + "created_at": now, + "updated_at": now, + } + ) + + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_1") + + run = db.get_run("run_1") + assert run["status"] == "COMPLETED" + artifacts = db.list_artifacts("run_1") + assert {item["name"] for item in artifacts} == {"step.text", "step.file_uri", "result"} + + +def test_execute_run_missing_workflow(tmp_path, monkeypatch) -> None: + """验证工作流记录缺失时任务被标记为失败。""" + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_missing", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "created_at": now, + "updated_at": now, + } + ) + monkeypatch.setattr(db, "get_workflow", lambda workflow_id: None) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_missing") + assert db.get_run("run_missing")["status"] == "FAILED" + + +def test_execute_run_missing_version(tmp_path) -> None: + """验证版本记录缺失时任务被标记为失败。""" + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_version", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_version") + assert db.get_run("run_version")["status"] == "FAILED" + + +def test_execute_run_missing_node(tmp_path) -> None: + """验证未注册节点被调用时任务失败。""" + db = _db(tmp_path) + definition = WorkflowDefinition( + name="bad", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="missing-node", + inputs={"text": "input.video_uri"}, + ) + ], + entry_inputs={"video_uri": "file"}, + ) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_node", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(tmp_path / "in.txt"), + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_node") + assert db.get_run("run_node")["status"] == "FAILED" + + +def test_resolve_ref_and_mime(tmp_path) -> None: + """验证输入引用解析、MIME 推断与文件大小读取。""" + db = _db(tmp_path) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + assert scheduler._resolve_ref("input.video", "in.mp4", {}) == "in.mp4" + assert ( + scheduler._resolve_ref( + "a.out", None, {"a": {"out": "result.txt"}} + ) + == "result.txt" + ) + assert scheduler._resolve_ref("a.out", None, {}) is None + assert scheduler._resolve_ref("nodot", "in.mp4", {}) is None + assert scheduler._mime_type("x.srt") == "application/x-subrip" + assert scheduler._mime_type("x.ass") == "text/plain" + assert scheduler._mime_type("x.wav") == "audio/wav" + assert scheduler._mime_type("x.mp4") == "video/mp4" + assert scheduler._mime_type("x.txt") == "text/plain" + assert scheduler._mime_type("x.bin") == "application/octet-stream" + existing = tmp_path / "existing.txt" + existing.write_text("x", encoding="utf-8") + assert scheduler._file_size(str(existing)) == 1 + missing = tmp_path / "missing.bin" + assert scheduler._file_size(str(missing)) == 0 + + +def test_execute_unknown_or_non_queued_run(tmp_path) -> None: + """验证未知任务或非排队任务会被忽略。""" + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_done", + "workflow_id": "flow", + "workflow_version": 1, + "status": "COMPLETED", + "progress": 1, + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("missing") + scheduler.execute_run("run_done") + assert db.get_run("run_done")["status"] == "COMPLETED" + + +def test_execute_missing_input(tmp_path) -> None: + """验证输入引用无法解析时任务失败。""" + db = _db(tmp_path) + definition = WorkflowDefinition( + name="missing-input", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="echo", + inputs={"text": "missing.output"}, + ) + ], + entry_inputs={"video_uri": "file"}, + ) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_input", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(tmp_path / "in.txt"), + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_input") + assert db.get_run("run_input")["status"] == "FAILED" + + +def test_execute_node_failed_response(tmp_path) -> None: + """验证节点返回 failed 时任务被标记为失败。""" + db = _db(tmp_path) + registry.register( + NodeManifest( + id="fail-node", + name="Fail", + version="1", + capability="echo", + repo_dir="nodes", + command=["python", "-m", "fail"], + ), + lambda request: InvokeResponse(status="failed", error="boom"), + ) + definition = WorkflowDefinition( + name="fail-flow", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="fail-node", + inputs={"text": "input.video_uri"}, + ) + ], + entry_inputs={"video_uri": "file"}, + ) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_fail_node", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(tmp_path / "in.txt"), + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_fail_node") + assert db.get_run("run_fail_node")["status"] == "FAILED" + + +def test_scheduler_start_stop_loop(tmp_path) -> None: + """验证调度线程可重复启动并正常停止。""" + db = _db(tmp_path) + scheduler = WorkflowScheduler(db, tmp_path / "storage", interval_seconds=0.05) + scheduler.start() + try: + scheduler.start() + time.sleep(0.15) + finally: + scheduler.stop() + assert scheduler._thread is None + + +def test_scheduler_background_executes_queued_run(tmp_path) -> None: + """验证后台线程会自动执行排队中的任务。""" + db = _db(tmp_path) + input_file = tmp_path / "input.txt" + input_file.write_text("background", encoding="utf-8") + _register_echo() + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, _echo_definition().to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_bg", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(input_file), + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage", interval_seconds=0.05) + scheduler.start() + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if db.get_run("run_bg")["status"] in {"COMPLETED", "FAILED"}: + break + time.sleep(0.1) + finally: + scheduler.stop() + assert db.get_run("run_bg")["status"] == "COMPLETED" + + +def test_final_artifact_renamed_with_language_tag(tmp_path) -> None: + """验证最终产物按 上传文件名.语言.时间戳 重命名并登记新 URI。""" + db = _db(tmp_path) + input_file = tmp_path / "movie01.mp4" + input_file.write_text("video", encoding="utf-8") + _register_echo() + definition = WorkflowDefinition( + name="lang-flow", + version=1, + nodes=[ + WorkflowNode( + id="step", + node_type="echo", + params={"target_language": "zh-CN"}, + inputs={"file_uri": "input.video_uri"}, + ) + ], + edges=[], + entry_inputs={"video_uri": "file"}, + final_outputs={"cn_srt": "step.file_uri"}, + ) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_1", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(input_file), + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_1") + artifacts = db.list_artifacts("run_1") + final = next(item for item in artifacts if item["name"] == "cn_srt") + filename = Path(final["uri"]).name + # 命名规则:movie01.zh-CN.<14位时间戳>.txt + assert filename.startswith("movie01.zh-CN.") + assert filename.endswith(".txt") + assert Path(final["uri"]).is_file() + # 原始未重命名文件不应残留。 + step_artifacts = [item for item in artifacts if item["name"] == "step.file_uri"] + assert not Path(step_artifacts[0]["uri"]).exists() + + +def test_final_artifact_renamed_fallback_base_and_tag(tmp_path) -> None: + """验证无上传文件时基础名回退 subtitle,无语言参数时标识回退别名。""" + db = _db(tmp_path) + _register_echo() + definition = WorkflowDefinition( + name="fallback-flow", + version=1, + nodes=[ + # 空输入让 echo 走默认文本路径,避免 input_uri 缺失导致解析失败。 + WorkflowNode(id="step", node_type="echo", inputs={}) + ], + entry_inputs={"video_uri": "file"}, + final_outputs={"result": "step.file_uri"}, + ) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + # 故意不提供 input_uri,验证基础名回退。 + db.create_run( + { + "id": "run_1", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": None, + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_1") + final = next(item for item in db.list_artifacts("run_1") if item["name"] == "result") + filename = Path(final["uri"]).name + # 基础名回退 subtitle、标识回退别名 result。 + assert filename.startswith("subtitle.result.") + assert filename.endswith(".txt") + + +def test_execute_run_merges_param_overrides(tmp_path) -> None: + """验证调度执行时把 param_overrides 合并进节点参数。""" + db = _db(tmp_path) + input_file = tmp_path / "input.txt" + input_file.write_text("x", encoding="utf-8") + captured = {} + + def recording_handler(request): + captured["params"] = dict(request.params) + return InvokeResponse(status="completed", outputs={"text": "ok"}) + + registry.register( + NodeManifest( + id="record-node", + name="Record", + version="1", + capability="echo", + repo_dir="nodes", + command=["python", "-m", "record"], + ), + recording_handler, + ) + definition = WorkflowDefinition( + name="ov-flow", + version=1, + nodes=[WorkflowNode(id="step", node_type="record-node", params={"base": 1})], + entry_inputs={"video_uri": "file"}, + ) + db.upsert_workflow({"id": "flow", "name": "F", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, definition.to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_ov", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "param_overrides": {"step": {"crop": [0, 0.5, 1, 0.5]}}, + "input_uri": str(input_file), + "created_at": now, + "updated_at": now, + } + ) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_ov") + assert captured["params"] == {"base": 1, "crop": [0, 0.5, 1, 0.5]} + + +def _two_node_definition() -> WorkflowDefinition: + """构造 a→b 两节点工作流:b 引用 a 的输出。""" + return WorkflowDefinition( + name="two-flow", + version=1, + nodes=[ + WorkflowNode(id="a", node_type="x", inputs={"video_uri": "input.video_uri"}), + WorkflowNode(id="b", node_type="x", inputs={"data_uri": "a.data_uri"}), + ], + edges=[WorkflowEdge(from_node="a", to_node="b")], + entry_inputs={"video_uri": "file"}, + final_outputs={"result": "b.data_uri"}, + ) + + +def test_execute_pause_between_nodes_and_resume(tmp_path, monkeypatch) -> None: + """验证运行中暂停:节点边界停下保持 PAUSED;续跑时跳过已完成节点。""" + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, _two_node_definition().to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_pause", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(tmp_path / "in.txt"), + "created_at": now, + "updated_at": now, + } + ) + data_file = tmp_path / "data.bin" + data_file.write_bytes(b"x") + + calls: list[str] = [] + + def fake_invoke(node_type, request): + # registry.invoke 首参是 node_type;用产物目录名(steps/)识别节点。 + node_id = Path(request.output_dir).name + calls.append(node_id) + # 第一个节点完成后立刻暂停任务,模拟用户在运行中点暂停。 + if node_id == "a": + db.pause_run("run_pause", now) + return InvokeResponse(status="completed", outputs={"data_uri": str(data_file)}) + + monkeypatch.setattr(registry, "invoke", fake_invoke) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_pause") + assert db.get_run("run_pause")["status"] == "PAUSED" + assert calls == ["a"] # 节点 b 未执行。 + + # 继续:恢复排队并再次执行,节点 a 已产出结果应被跳过,只执行 b。 + db.resume_run("run_pause", now) + scheduler.execute_run("run_pause") + assert db.get_run("run_pause")["status"] == "COMPLETED" + assert calls == ["a", "b"] + artifacts = db.list_artifacts("run_pause") + assert {item["name"] for item in artifacts} == {"a.data_uri", "b.data_uri", "result"} + + +def test_execute_paused_run_not_run(tmp_path, monkeypatch) -> None: + """验证非可执行状态(如 RUNNING 之外的值)的任务不会被执行。""" + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, _two_node_definition().to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_done", + "workflow_id": "flow", + "workflow_version": 1, + "status": "COMPLETED", + "progress": 1.0, + "created_at": now, + "updated_at": now, + } + ) + called = [] + + def fake_invoke(node_id, request): + called.append(node_id) + return InvokeResponse(status="completed", outputs={}) + + monkeypatch.setattr(registry, "invoke", fake_invoke) + WorkflowScheduler(db, tmp_path / "storage").execute_run("run_done") + assert called == [] + + +def test_execute_pause_after_last_node_keeps_paused(tmp_path, monkeypatch) -> None: + """验证全部节点完成但运行中被暂停时保持 PAUSED;续跑补做收尾后完成。""" + db = _db(tmp_path) + db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1}) + db.create_workflow_version("flow", 1, _two_node_definition().to_dict()) + now = "2026-01-01T00:00:00+00:00" + db.create_run( + { + "id": "run_tail", + "workflow_id": "flow", + "workflow_version": 1, + "status": "QUEUED", + "progress": 0, + "input_uri": str(tmp_path / "in.txt"), + "created_at": now, + "updated_at": now, + } + ) + data_file = tmp_path / "data.bin" + data_file.write_bytes(b"x") + calls: list[str] = [] + + def fake_invoke(node_type, request): + node_id = Path(request.output_dir).name + calls.append(node_id) + if node_id == "b": # 最后一个节点执行时暂停。 + db.pause_run("run_tail", now) + return InvokeResponse(status="completed", outputs={"data_uri": str(data_file)}) + + monkeypatch.setattr(registry, "invoke", fake_invoke) + scheduler = WorkflowScheduler(db, tmp_path / "storage") + scheduler.execute_run("run_tail") + # 全部节点已执行,但收尾前被暂停 → 保持 PAUSED 而不是 COMPLETED。 + assert db.get_run("run_tail")["status"] == "PAUSED" + assert calls == ["a", "b"] + + # 续跑:节点产物齐备全部跳过,补做收尾后完成。 + db.resume_run("run_tail", now) + scheduler.execute_run("run_tail") + assert db.get_run("run_tail")["status"] == "COMPLETED" + assert calls == ["a", "b"] diff --git a/tests/test_seed.py b/tests/test_seed.py new file mode 100644 index 0000000..f592fc9 --- /dev/null +++ b/tests/test_seed.py @@ -0,0 +1,108 @@ +"""种子数据测试。 + +验证从 workflows/*.json 数据文件加载默认工作流、幂等性,以及 +开启种子与调度器后的应用生命周期。工作流定义来自数据文件而非代码。 +""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from wov_app.db import Database +from wov_app.main import app +from wov_app.seed import seed_default_workflows + +# 单体根目录:tests/ 的上一级。 +WORKSPACE = Path(__file__).resolve().parent.parent + + +def test_seed_default_workflows_idempotent(tmp_path) -> None: + """验证从数据文件加载 demo/zh-direct 两个工作流且重复调用幂等。""" + db = Database(tmp_path / "wov.db") + created = seed_default_workflows(db) + assert created == 3 + assert db.get_workflow("demo") is not None + assert db.get_workflow("zh-direct") is not None + + # demo:asr 显式声明 model_path 与长音频参数,模型选择完全数据化。 + demo = db.get_latest_workflow_version("demo")["definition"] + assert demo["name"] == "视频字幕生成" + demo_asr = next(node for node in demo["nodes"] if node["id"] == "asr") + assert demo_asr["params"]["model_path"] == "faster-whisper-large-v3" + assert demo_asr["params"]["condition_on_previous_text"] is False + + # zh-direct:使用中文直出模型并开启翻译任务。 + zh = db.get_latest_workflow_version("zh-direct")["definition"] + assert zh["name"] == "中文直出字幕" + zh_asr = next(node for node in zh["nodes"] if node["id"] == "asr") + assert zh_asr["params"]["model_path"] == "whisper-large-v2-translate-zh-v0.2-st-ct2" + assert zh_asr["params"]["task"] == "translate" + + # 再次调用不重复创建。 + assert seed_default_workflows(db) == 0 + assert len(db.list_workflow_versions("demo")) == 1 + + +def test_seed_custom_dir_and_empty(tmp_path) -> None: + """验证自定义数据目录的加载与空目录返回 0。""" + db = Database(tmp_path / "wov.db") + custom = tmp_path / "workflows" + custom.mkdir() + (custom / "a.json").write_text( + """ + { + "id": "flow-a", + "name": "Flow A", + "description": "custom", + "version": 1, + "definition": { + "name": "Flow A", + "version": 1, + "nodes": [{"id": "step", "node_type": "echo"}], + "edges": [], + "entry_inputs": {}, + "final_outputs": {} + } + } + """, + encoding="utf-8", + ) + assert seed_default_workflows(db, custom) == 1 + assert db.get_workflow("flow-a") is not None + # 空目录返回 0。 + empty = tmp_path / "empty" + empty.mkdir() + assert seed_default_workflows(db, empty) == 0 + # 已存在的工作流被跳过。 + assert seed_default_workflows(db, custom) == 0 + + +def test_lifespan_with_seed_and_scheduler(monkeypatch) -> None: + """验证启用自动种子与调度器后应用正常启动,demo 与中文直出应用均可见。""" + monkeypatch.setenv("WOV_AUTO_SEED", "1") + monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "1") + with TestClient(app) as client: + apps = client.get("/api/apps") + assert apps.status_code == 200 + assert any(item["id"] == "demo" for item in apps.json()) + assert any(item["id"] == "zh-direct" for item in apps.json()) + + +def test_seed_workflows_have_chunk_seconds(tmp_path) -> None: + """验证内置工作流的 asr 节点均显式声明分块参数。""" + db = Database(tmp_path / "wov.db") + seed_default_workflows(db) + for workflow_id in ("demo", "zh-direct"): + definition = db.get_latest_workflow_version(workflow_id)["definition"] + asr = next(node for node in definition["nodes"] if node["id"] == "asr") + assert asr["params"]["chunk_seconds"] == 60 + + +def test_seed_workflows_vad_filter_off(tmp_path) -> None: + """验证内置工作流 asr 显式开启 VAD。""" + db = Database(tmp_path / "wov.db") + seed_default_workflows(db) + for workflow_id in ("demo", "zh-direct"): + definition = db.get_latest_workflow_version(workflow_id)["definition"] + asr = next(node for node in definition["nodes"] if node["id"] == "asr") + assert asr["params"]["vad_filter"] is True diff --git a/tests/test_uvicorn_smoke.py b/tests/test_uvicorn_smoke.py new file mode 100644 index 0000000..82202b4 --- /dev/null +++ b/tests/test_uvicorn_smoke.py @@ -0,0 +1,34 @@ +"""uvicorn 冒烟测试。 + +用真实套接字启动 uvicorn 服务并请求 /health,验证应用能脱离 TestClient +在实际 Web 服务环境中正常工作。 +""" + +import threading +import time +import urllib.request + +from uvicorn import Config, Server + +from wov_app.main import app + + +def test_uvicorn_serves_app_over_real_socket() -> None: + """验证 uvicorn 监听真实端口后健康检查可用。""" + config = Config(app=app, host="127.0.0.1", port=0, log_level="error") + server = Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + try: + deadline = time.monotonic() + 10 + while not server.started and time.monotonic() < deadline: + time.sleep(0.05) + assert server.started + + port = server.servers[0].sockets[0].getsockname()[1] + with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as response: + assert response.status == 200 + assert b'"wov-api"' in response.read() + finally: + server.should_exit = True + thread.join(timeout=10) diff --git a/tests/test_workflow_api.py b/tests/test_workflow_api.py new file mode 100644 index 0000000..da3909b --- /dev/null +++ b/tests/test_workflow_api.py @@ -0,0 +1,117 @@ +"""工作流管理 API 测试。 + +覆盖工作流的创建、查询、校验、发布、版本列表与删除等管理接口。 +""" + +from fastapi.testclient import TestClient + +from wov_app.main import app + + +def definition() -> dict: + """构造一个引用 Echo 节点的合法工作流定义。""" + return { + "name": "echo-flow", + "version": 1, + "nodes": [ + { + "id": "step", + "node_type": "echo", + "inputs": {"file_uri": "input.video_uri"}, + } + ], + "edges": [], + "entry_inputs": {"video_uri": "file"}, + "final_outputs": {"result": "step.file_uri"}, + } + + +def test_workflow_crud_and_publish() -> None: + """验证工作流 CRUD、校验、发布与版本列表的完整流程。""" + with TestClient(app) as client: + created = client.post( + "/api/admin/workflows", + json={ + "id": "echo-flow", + "name": "Echo Flow", + "description": "demo", + "definition": definition(), + }, + ) + assert created.status_code == 200 + assert created.json()["id"] == "echo-flow" + + assert client.get("/api/admin/workflows").status_code == 200 + assert client.get("/api/admin/workflows/echo-flow").status_code == 200 + assert client.get("/api/admin/workflows/missing").status_code == 404 + + validated = client.post( + "/api/admin/workflows/echo-flow/validate", + json=definition(), + ) + assert validated.status_code == 200 + assert validated.json()["valid"] is True + assert client.post( + "/api/admin/workflows/missing/validate", + json=definition(), + ).status_code == 404 + + published = client.post("/api/admin/workflows/echo-flow/publish") + assert published.status_code == 200 + assert published.json()["published"] == "echo-flow" + assert client.post("/api/admin/workflows/missing/publish").status_code == 404 + + versions = client.get("/api/admin/workflows/echo-flow/versions") + assert versions.status_code == 200 + assert len(versions.json()) == 1 + assert client.get("/api/admin/workflows/missing/versions").status_code == 404 + + assert client.delete("/api/admin/workflows/echo-flow").status_code == 200 + assert client.delete("/api/admin/workflows/echo-flow").status_code == 404 + + +def test_workflow_slug_without_id() -> None: + """验证未提供 ID 时后端会从名称生成 slug。""" + with TestClient(app) as client: + created = client.post( + "/api/admin/workflows", + json={ + "name": "Echo Flow", + "definition": definition(), + }, + ) + assert created.status_code == 200 + assert created.json()["id"] == "echo-flow" + + +def test_workflow_validation_error() -> None: + """验证重复节点 ID 的 DAG 会被拒绝。""" + with TestClient(app) as client: + response = client.post( + "/api/admin/workflows", + json={ + "id": "bad", + "name": "Bad", + "definition": { + "name": "Bad", + "version": 1, + "nodes": [ + {"id": "a", "node_type": "x"}, + {"id": "a", "node_type": "y"}, + ], + "edges": [], + }, + }, + ) + assert response.status_code == 422 + + +def test_publish_workflow_without_version() -> None: + """验证没有版本记录的工作流不能发布。""" + with TestClient(app) as client: + db = app.state.db + db.upsert_workflow( + {"id": "empty", "name": "Empty", "published": 0, "latest_version": 0} + ) + response = client.post("/api/admin/workflows/empty/publish") + assert response.status_code == 422 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..54dad0c --- /dev/null +++ b/uv.lock @@ -0,0 +1,1106 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "av" +version = "18.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/f4/f22114d30d3435e38c6af2b4870f37b864403dca6ae7af747a289ce0a18e/av-18.1.0.tar.gz", hash = "sha256:47bfc286e1bc9de7ab4681fc2b575cd2460a66919d31ffe1bd5aa54fae531a28", size = 4451061, upload-time = "2026-08-12T22:28:18.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/d4/d7cdc8bff143c17a6d35924375ae28dd692cacde38700a7d419fde54f44a/av-18.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ae75d8bb6467895ed1f8572ededf7ffa49eac07f6e483222f5d7d62a41d12f04", size = 22546147, upload-time = "2026-08-12T22:27:11.851Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c9/37a619297492256b77d5ed906e7d8166c10a26ed251dccf1ae03ab19bff6/av-18.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:b30a4e8d934558e19602b68998a4d9ac9f250fa0dacef216f7e8e40153b13316", size = 18217603, upload-time = "2026-08-12T22:27:14.713Z" }, + { url = "https://files.pythonhosted.org/packages/d9/84/2464ffb64c08c5ce8b522c8e74594714414e3b0575267652c5c51c0574b9/av-18.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6fc837cc51adf80331ac850779cd53b5d4c4460b0ebe9057a02a921c6736f19d", size = 33640142, upload-time = "2026-08-12T22:27:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/204dbfc3e08eb4cdc6e6ff57be02150bc44523ebdb50182d10025792ebd9/av-18.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a032e8d8ebc73dec079364b9b4a6837638a2d106e8472314e685ffbf163e700", size = 35786210, upload-time = "2026-08-12T22:27:20.984Z" }, + { url = "https://files.pythonhosted.org/packages/e1/99/b0d04ec553ff9a7e00455458dfa3a39c8a8f627b273056b4e5fe57d590de/av-18.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:3c8b1f8b46f99d52e2d8b0ed5d0cdadf172d24794d46e2077b16e44ed08e26ff", size = 39379798, upload-time = "2026-08-12T22:27:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/56/b1/e00d4feae59160149df6126585e726fdc6300798fd40c5dd324879e81f68/av-18.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab5ac081bc9eaf54109120d4e56284674fecfbe520d9aa1707c7fa911ec5f4d2", size = 34690321, upload-time = "2026-08-12T22:27:27.769Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/836fa987e3084d11a21489f11357fb24843ef3aa8faf74ddddfc603d5062/av-18.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:191224788d87af06c31784a395bb73f14b72f33d7f4871ace0157de2abdc6276", size = 36859932, upload-time = "2026-08-12T22:27:31.403Z" }, + { url = "https://files.pythonhosted.org/packages/33/b4/76ba21e46704f632004276b85289a1582e95f5eff760436d6149875a1881/av-18.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:ea1480b7a8d5405cb5f382b344731bf125fd2c1c6fae3964f6c48595628387ff", size = 27595679, upload-time = "2026-08-12T22:27:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ad/a3135884c5753b09773176b97201ae602f67ad14206c395ff838d66bf9b0/av-18.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:5509ec12aaa19fd6601de13cfa6f4cdad450da07982118510592875d970454d6", size = 20257584, upload-time = "2026-08-12T22:27:38.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5b/4a756265d7fb164336c8d377bca21c39cfa2c178be23cedee840a69b59c5/av-18.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b36b0bae9e4c62f9487c99481ec15e4e3870fcc868522cd6d18fc2d6bfa04f01", size = 22795654, upload-time = "2026-08-12T22:27:42.016Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cc/1bc841462114a1adf4f7d87456ab78a6972e23271e71865fcd2bbd0e7360/av-18.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:025f84494cb23278498f03b0d8117d3e47a1cbc9c44b97eb31875cf02251e46b", size = 18435735, upload-time = "2026-08-12T22:27:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/b8/20/005500ed17a2e62a5e4bb94aa3786942560ec2f55ec1895ebf174c87abef/av-18.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:08a9ae288299cfcbf739dba4ad0c53b9b71f45184303dd45947920d022fed695", size = 37090807, upload-time = "2026-08-12T22:27:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f7/11e7f6d848d3690c31ca4f8578167393e619177f1493ccc93b9400852d4e/av-18.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cf8a17466bef07765dbdecc9e66ed9b25d20b4e14f654fbf35345a58ac45fa0c", size = 38976836, upload-time = "2026-08-12T22:27:54.565Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/b271473b24e806062d31191e40c6d65545e9cf59f80f044eba56dcbba0f4/av-18.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d49a5c542dfdc00f43c6cdb6cc41dac1781ee206fe180b56aa7433dfa816dfae", size = 40896630, upload-time = "2026-08-12T22:27:59.118Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9f/2ab7fa292a947ad3466ed8e655eefa3b82f535d7ea598c297b4471a937c4/av-18.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5548b79e2bf1f59b3e9aedc918a72d9dc45b9adaac10ff9470d5dbdda0002e47", size = 37895673, upload-time = "2026-08-12T22:28:03.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/04507c57249b399c3e4f23f01d221532f357338b5316fd2858fbd343127d/av-18.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7ea063f6690193ea335a1d592d6e0274350d45e2ed6af83ee107cb90cbfd84f", size = 39992431, upload-time = "2026-08-12T22:28:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d6/bc4b95bea9c2353a7e4d62a3fcfad9adcf0f881741c6ce01ee179d539ce3/av-18.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e4d48b9f12cad009cc72fe4f4099107de5e819c95f82767f4fd01a01481c0661", size = 28497798, upload-time = "2026-08-12T22:28:13.003Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d2/0c277a46f12647c1833f40496e132fb6001e0d19e6144b5ea30896461feb/av-18.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5cd9085028902c9880622bd37a12fd4b33060f06a52311f6f4867ca9f29a2c3b", size = 21421979, upload-time = "2026-08-12T22:28:16.48Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "ctranslate2" +version = "4.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c4/0e450796f90e54f3325697fc67db4f4ecd397aef96d7b3924e26fb8bd04b/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c2db633a06e3b34bbfb72fd26eee58053d9df1f9c1610ac4df3a6a1e25af7d7", size = 1270559, upload-time = "2026-07-03T12:39:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/b7/54/7b6db16470d0788fb8ab43a99e3e18ba9d41a9b50b7fef7dec353eafbe20/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:079976cbce3a68de04bf9948d08c96beb86df44e5cd2974e4187bc9c9bb388f3", size = 11928069, upload-time = "2026-07-03T12:39:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/8fee1366631d224bf26b34db9063a0c88ce358d58331c2393689b0ea27ff/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74bae0a8dc9f98c5a6100bf1c17a91782b384ea53b83e2606030ebf9f25318fe", size = 16707971, upload-time = "2026-07-03T12:39:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/30/84/f610e90bb419707632b9b668476b9fd4cdb090c9b53c119ce017699b58ca/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a584c17f21779eb9035bcbc1ec280998f90b36725b70a5ff911f33e343199a", size = 39351971, upload-time = "2026-07-03T12:39:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/76/6c/7230ecbdd23ab867715e1b6ffe99211c39c11cae8ec2d6c3ec9208c38ee2/ctranslate2-4.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:82982f07a7d615d2248d17d6ec4c43cd50e534b094aa27cda62125a5e3a6e3fc", size = 19219248, upload-time = "2026-07-03T12:39:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/6d/09/9a50eeab00db68aeac08f6ab7f98b5c36abd26b89cbd707ea39e70656500/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9de0dddd91ae68da0a7323441e90708d14b31d31cd443004dda0e1198b5bf11e", size = 1270522, upload-time = "2026-07-03T12:39:13.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/6c41c4d3ae539ec76b1943c362184677befd7c1d5290d2ec361182cdb1e0/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:82e0e6eb7d4301fd79a714495c8faf34242e09542cef04c9e9794c3fe90014a1", size = 11930367, upload-time = "2026-07-03T12:39:14.896Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/03428106134a0a58922461074f8942f92c5ed0bb3a8d018677ad64a9c476/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ca144b93035b9f53e6d67b7cdf5802c3fffca9aa0247940eecbd4592c68ce2f", size = 16882768, upload-time = "2026-07-03T12:39:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/47/c9/976a565398a03fb2973cbe5edd5ca03c4332d86b634799e0ee562420d3bc/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dacc408f716ebc73b3b3c6ddd937700e776c4c68b6d9c81862990150ff0f6af6", size = 39529060, upload-time = "2026-07-03T12:39:20.468Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/0a5f7f2b03b4e10aacb3146715724e1b96bb993cc7d199be28c9825aa120/ctranslate2-4.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:49f96e861b57301f0b76a082109bde2cac8204a6b4fedc870883008271e82251", size = 19220789, upload-time = "2026-07-03T12:39:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/3101c3a0785253a8ef386f39744ad19c28c75b7f227e7c232aee7a5c416a/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931", size = 1270478, upload-time = "2026-07-03T12:39:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/89/b9/e50c7558e96a054d6b1e6a6c5e729dda4a4f05584e065f2902aa5f1bc4c8/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940", size = 11930241, upload-time = "2026-07-03T12:39:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/ea7a19c6d7e949b731fb034664633184bbfc7882846d107f4d790693fb76/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d", size = 16883512, upload-time = "2026-07-03T12:39:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", size = 39529085, upload-time = "2026-07-03T12:39:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/37da1a7500b57496a5269318c4f57962ea0c26dcac06b85222d7831acf00/ctranslate2-4.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827", size = 19220784, upload-time = "2026-07-03T12:39:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/c6/66/39111224e418400d97fd79fbc9e72329c51f91a3e7a9c9a1a182e4f88022/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621", size = 1271321, upload-time = "2026-07-03T12:39:37.907Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/13f827fae226eea51315729c00111f716813d7736ebb827fecb8f361fe0d/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a", size = 11930735, upload-time = "2026-07-03T12:39:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/c9/94/4b73f9bbaba29df4227cc65114f11d83fe6d696ef3705cb1ade79eb118fd/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34", size = 16872460, upload-time = "2026-07-03T12:39:42.272Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/9816494d5ff0745bdf9abe5af04e57a103a416444e604cbe83a6eb0aed7b/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8", size = 39494736, upload-time = "2026-07-03T12:39:45.733Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/22a2c874ca8bb6caa7018dfefdff92dddd487db31cf169891c4c6d408091/ctranslate2-4.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1", size = 19477164, upload-time = "2026-07-03T12:39:48.952Z" }, + { url = "https://files.pythonhosted.org/packages/77/39/7b8d47bf49748ba73182742683eef74b46608beb879765d9d4efc46bc345/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795", size = 1293935, upload-time = "2026-07-03T12:39:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/c1/20/434e30c752c433eaef5deccd4de54775bc1f205a6fe6c9e756b737018209/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627", size = 11951789, upload-time = "2026-07-03T12:39:52.886Z" }, + { url = "https://files.pythonhosted.org/packages/85/f2/d716426220b462bbb5bb354b9c6c8d9a41285f067203c860cc79f9f19917/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d", size = 16860414, upload-time = "2026-07-03T12:39:55.54Z" }, + { url = "https://files.pythonhosted.org/packages/69/11/cdab0e7e2ad4e547f15ab227c09207569f1272abae05816900ecebb0797a/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea", size = 39465200, upload-time = "2026-07-03T12:39:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "faster-whisper" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[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, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[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, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[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, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { 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 = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/ddf3d02a8681f1b9ce52fda03d755dad6b74c4f8172304c4c8d2975450f9/huggingface_hub-1.27.0.tar.gz", hash = "sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df", size = 942668, upload-time = "2026-08-07T12:48:05.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d8/95b735e183957c1f26d94c52977f09d466d55119cbbc1558ea4975e4c216/huggingface_hub-1.27.0-py3-none-any.whl", hash = "sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d", size = 784926, upload-time = "2026-08-07T12:48:02.905Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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, upload-time = "2025-01-16T21:34:32.747Z" } +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, upload-time = "2025-01-16T21:34:20.464Z" }, + { 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, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { 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, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.9.2.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/a2/c96163a0fff1839c0c9548bbdeae7b853b867009e33b9b9264adc238b1cf/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:5572131a59c3eebeeb1c4c8144f772d49372c20124916e072a0e3fc30df421d5", size = 575012079, upload-time = "2026-04-08T18:51:47.303Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c0/0a517bfe63ccd3b92eb254d264e28fca3c7cab75d07daea315250fb1bf73/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:e4f53a8ca8c5d6e8c492d0d0a3d565ecb59a751b19cfdaa4f6da0ab2104c1702", size = 581240110, upload-time = "2026-04-08T18:52:31.532Z" }, + { url = "https://files.pythonhosted.org/packages/20/e2/fc9a0e985249d873150276d5afb02e39a66817fedbf1a385724393e505ed/nvidia_cublas_cu12-12.9.2.10-py3-none-win_amd64.whl", hash = "sha256:623f43027d40d44ceadf0043f002bd25cf353e8f13ce90b9a87057019f560661", size = 553162896, upload-time = "2026-04-08T18:53:10.035Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.9.86" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/85/e4af82cc9202023862090bfca4ea827d533329e925c758f0cde964cb54b7/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:210cf05005a447e29214e9ce50851e83fc5f4358df8b453155d5e1918094dcb4", size = 89568129, upload-time = "2025-06-05T20:02:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/64/eb/c2295044b8f3b3b08860e2f6a912b702fc92568a167259df5dddb78f325e/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:096d4de6bda726415dfaf3198d4f5c522b8e70139c97feef5cd2ca6d4cd9cead", size = 44528905, upload-time = "2025-06-05T20:02:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/52/de/823919be3b9d0ccbf1f784035423c5f18f4267fb0123558d58b813c6ec86/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:72972ebdcf504d69462d3bcd67e7b81edd25d0fb85a2c46d3ea3517666636349", size = 76408187, upload-time = "2025-06-05T20:12:27.819Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.24.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/f1/cd42563325fa827f54ff30da05686c747652bdbd4cb5654cea54d7d0ad4f/nvidia_cudnn_cu12-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a42996943f0cd78ddfd61c8bf59361672a19b63e0491aa22a53d6fe63a3f854a", size = 856490582, upload-time = "2026-07-02T16:21:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/10/13/b8887c869cf2471339a24b60d3c28e761facbb534935f572b61423371abb/nvidia_cudnn_cu12-9.24.0.43-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:f424192dd85e7d29f44be18df2dae4c80d32c67a29c0d42f5c283c40cfdf871c", size = 799083985, upload-time = "2026-07-02T16:25:37.467Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/2c9a2a97a8b3fedcf74a14f38fd5edfae12274380a829fdc6b16ce29be4c/nvidia_cudnn_cu12-9.24.0.43-py3-none-win_amd64.whl", hash = "sha256:cbd41a0ab084422c936dc9fb2fc89be5ea9a85bc421c6f23d0243bdfc945fbef", size = 737103728, upload-time = "2026-07-02T16:30:10.901Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/4d/5014667e2a3a77d6e1b74cc3d88948d06163b8e0a33a84c85073322b5dec/onnxruntime-1.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410", size = 19130506, upload-time = "2026-07-25T01:22:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/97/b7ce1bc8bb6048b5fe9129f55d6506dc19499068ef2e0a0af1ae3c8aa4e7/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66f9ceb29909c70839e4e4fb3435c7b490050d8f162bd5f3aba4ca01ee517f", size = 17039880, upload-time = "2026-07-25T01:21:37.538Z" }, + { url = "https://files.pythonhosted.org/packages/f3/17/4e5ecd8764f87573c495d834ce79e61ecca47f7a01d1e444a606e570edcb/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a166b78ee04f3a37fa1ef82034b6a3ce96d9684e582d4d30b296de83e9998bb5", size = 19193162, upload-time = "2026-07-25T01:21:59.151Z" }, + { url = "https://files.pythonhosted.org/packages/9f/10/3d946d5d5f2cdcc3c8da36cae63190c516d16349edaffd944bda60ca4c3e/onnxruntime-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d650aeee29368414367b65529e90afe4bf1bab76254789063b8b2f7ea3013c8", size = 13752539, upload-time = "2026-07-25T01:22:24.524Z" }, + { url = "https://files.pythonhosted.org/packages/8f/74/1c440be7af1e026280b139caa1be5d11bd4dc368011ddbe8f5362b58e12f/onnxruntime-1.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:0faf85fb447a663c9cdadc39bd6b19bdf7bedded6699e45731b9b36c46fd993d", size = 13449940, upload-time = "2026-07-25T01:22:14.97Z" }, + { url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" }, + { url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" }, + { url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" }, + { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[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, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[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, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[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, upload-time = "2025-09-25T21:33:16.546Z" } +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, upload-time = "2025-09-25T21:31:58.655Z" }, + { 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, upload-time = "2025-09-25T21:32:00.088Z" }, + { 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, upload-time = "2025-09-25T21:32:01.31Z" }, + { 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, upload-time = "2025-09-25T21:32:03.376Z" }, + { 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, upload-time = "2025-09-25T21:32:04.553Z" }, + { 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, upload-time = "2025-09-25T21:32:06.152Z" }, + { 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, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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, upload-time = "2025-09-25T21:32:11.445Z" }, + { 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, upload-time = "2025-09-25T21:32:12.492Z" }, + { 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, upload-time = "2025-09-25T21:32:13.652Z" }, + { 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, upload-time = "2025-09-25T21:32:15.21Z" }, + { 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, upload-time = "2025-09-25T21:32:16.431Z" }, + { 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, upload-time = "2025-09-25T21:32:17.56Z" }, + { 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, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" }, +] + +[[package]] +name = "vrsub" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "faster-whisper" }, + { name = "imageio-ffmpeg" }, + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cudnn-cu12" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi" }, + { name = "faster-whisper", specifier = ">=1.2.1" }, + { name = "imageio-ffmpeg", specifier = ">=0.6" }, + { name = "nvidia-cublas-cu12", specifier = ">=12.9.2.10" }, + { name = "nvidia-cudnn-cu12", specifier = ">=9.24.0.43" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] diff --git a/web/admin.html b/web/admin.html new file mode 100755 index 0000000..b8ea96b --- /dev/null +++ b/web/admin.html @@ -0,0 +1,46 @@ + + + + + + + VRSub 管理后台 + + + +
+ VRSub + +
+ +
+

管理后台

+ + +
+

已发布工作流

+
+ + + + + + + + + + + +
ID名称版本状态操作
+
+
+
+ + + + diff --git a/web/assets/app.js b/web/assets/app.js new file mode 100644 index 0000000..fbdb2ca --- /dev/null +++ b/web/assets/app.js @@ -0,0 +1,647 @@ +// VRSub 静态前端公共脚本:所有页面共用的 API 封装、渲染函数与事件绑定。 +// 首页只负责发起任务;任务管理页展示全部任务的进度、产物下载与失败重试。 + +// 演示"视频字幕生成"工作流的 DAG 定义,预填在工作流编排页。 +const DEMO_WORKFLOW = { + name: "视频字幕生成", + version: 1, + nodes: [ + { + id: "extract", + node_type: "ffmpeg-extract", + params: { sample_rate: 16000, channels: 1 }, + inputs: { video_uri: "input.video_uri" }, + }, + { + id: "asr", + node_type: "faster-whisper", + params: { language: "ja" }, + inputs: { audio_uri: "extract.audio_uri" }, + }, + { + id: "translate", + node_type: "llm-translate", + params: { target_language: "zh-CN" }, + inputs: { srt_uri: "asr.srt_uri" }, + }, + { + id: "ass", + node_type: "srt-to-dual-eye-ass", + params: { resolution: "3840x1920" }, + inputs: { cn_srt_uri: "translate.cn_srt_uri" }, + }, + ], + edges: [ + { from: "extract", to: "asr" }, + { from: "asr", to: "translate" }, + { from: "translate", to: "ass" }, + ], + entry_inputs: { video_uri: "file" }, + final_outputs: { + cn_srt: "translate.cn_srt_uri", + ass: "ass.ass_uri", + }, +}; + +// 统一封装 fetch:自动携带 JSON 头、解析响应并在失败时抛出可读错误。 +async function api(path, options = {}) { + const response = await fetch(path, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + // FastAPI 的校验错误 detail 可能是数组,统一序列化为字符串展示。 + const detail = data && data.detail ? JSON.stringify(data.detail) : response.statusText; + throw new Error(`${response.status} ${detail}`); + } + return data; +} + +// 转义用户可控文本,防止 XSS 注入到表格或状态 HTML 中。 +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +// 根据状态生成带语义颜色的徽章 HTML。 +function badge(status) { + // 统一转小写比较,兼容后端返回的不同大小写。 + const value = String(status).toLowerCase(); + const className = + value === "completed" || value === "ok" + ? "ok" + : value === "error" || value === "failed" + ? "error" + : "warn"; + return `${escapeHtml(status)}`; +} + +// 把 ISO 时间格式化为本地时间;非法值原样返回。 +function formatTime(value) { + if (!value) return ""; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +// 计算距给定时间的流逝时长,用于任务页展示已运行时间。 +function formatElapsed(value) { + if (!value) return ""; + const seconds = Math.floor((Date.now() - new Date(value).getTime()) / 1000); + if (Number.isNaN(seconds) || seconds < 0) return ""; + if (seconds < 60) return `${seconds} 秒`; + return `${Math.floor(seconds / 60)} 分钟`; +} + +// 健康检查失败时在页面顶部展示后端不可用提示。 +async function loadHealth() { + try { + await api("/health"); + } catch (error) { + const banner = document.createElement("div"); + banner.className = "result"; + banner.textContent = `后端不可用:${error.message}`; + document.body.prepend(banner); + } +} + +// 加载工作流列表并渲染发布状态与操作按钮。 +async function loadWorkflows() { + const workflows = await api("/api/admin/workflows"); + const tbody = document.getElementById("workflowList"); + if (!tbody) return; + tbody.innerHTML = workflows.length + ? workflows + .map( + (workflow) => ` + + ${escapeHtml(workflow.id)} + ${escapeHtml(workflow.name)} + ${escapeHtml(workflow.latest_version)} + ${workflow.published ? badge("ok") : badge("draft")} + + + ${workflow.published ? "" : ``} + + `, + ) + .join("") + : '暂无工作流'; +} + +// 加载已发布的工作流(应用),填充首页的下拉选择框。 +// 已发布工作流缓存(id → {name, definition}),供按需切换 OCR 面板。 +let workflowApps = []; +// 需要 crop 的节点 ID(如 frame-extract)与用户框选的 crop 值。 +let cropNodeId = null; +let selectedCrop = null; +// 框选模式:开启后才让画布接收鼠标事件,平时不拦截视频控件。 +let drawMode = false; +// 当前选择的视频文件:change 时保存,提交时使用(避免被重置清空 input 丢失)。 +let selectedVideoFile = null; + +async function loadWorkflowOptions() { + const select = document.getElementById("workflowSelect"); + if (!select) return; + workflowApps = await api("/api/apps"); + select.innerHTML = workflowApps.length + ? workflowApps + .map( + (app) => + ``, + ) + .join("") + : ''; + // 切换工作流时按需展示框选面板。 + select.addEventListener("change", onWorkflowChange); + await onWorkflowChange(); +} + +// 切换工作流:若 DAG 中存在带 crop 参数的节点(字幕 OCR 流程), +// 切换到框选面板;否则使用标准上传。 +async function onWorkflowChange() { + const select = document.getElementById("workflowSelect"); + const ocrCard = document.getElementById("ocrCard"); + const standardCard = document.getElementById("standardCard"); + if (!select || !ocrCard || !standardCard) return; + cropNodeId = null; + selectedCrop = null; + const app = workflowApps.find((item) => item.id === select.value); + // 数据驱动判断:节点 params 中声明了 crop 即需要框选。 + const cropNode = (app?.definition?.nodes || []).find( + (node) => node.params && node.params.crop !== undefined, + ); + const needsCrop = Boolean(cropNode); + cropNodeId = needsCrop ? cropNode.id : null; + ocrCard.hidden = !needsCrop; + standardCard.hidden = needsCrop; + resetOcrPanel(); + if (needsCrop) { + document.getElementById("ocrProgress").textContent = "请选择视频并框选字幕区域"; + } +} + +// 重置 OCR 面板:清除视频、画布与框选状态。 +function resetOcrPanel() { + const video = document.getElementById("ocrVideo"); + const canvas = document.getElementById("ocrCanvas"); + const fileInput = document.getElementById("ocrVideoFile"); + const cropValue = document.getElementById("cropValue"); + const submit = document.getElementById("ocrSubmit"); + selectedCrop = null; + if (video) video.removeAttribute("src"); + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + if (fileInput) fileInput.value = ""; + if (cropValue) cropValue.value = ""; + if (submit) submit.disabled = true; + selectedVideoFile = null; + exitDrawMode(); +} + +// 用 crop.js 把画布上的框选矩形归一化为 crop 比例并展示。 +function applyCropRect(rect) { + const video = document.getElementById("ocrVideo"); + const box = document.getElementById("videoBox"); + if (!video.videoWidth || !box) return; + const crop = rectToCrop( + rect, + video.videoWidth, + video.videoHeight, + box.clientWidth, + box.clientHeight, + ); + selectedCrop = crop; + document.getElementById("cropValue").value = crop.join(", "); + document.getElementById("ocrSubmit").disabled = false; + document.getElementById("ocrProgress").textContent = + `已框选 crop=[${crop.join(", ")}],可提交任务`; +} + +// 进入框选模式:暂停视频、隐藏原生控件、启用画布绘制。 +function enterDrawMode() { + const video = document.getElementById("ocrVideo"); + const canvas = document.getElementById("ocrCanvas"); + const toggle = document.getElementById("ocrDrawMode"); + if (!video.videoWidth) { + document.getElementById("ocrProgress").textContent = "请先选择视频并定位到有字幕的画面"; + return; + } + drawMode = true; + video.pause(); + video.removeAttribute("controls"); // 隐藏进度条,避免遮挡框选操作。 + canvas.classList.add("drawable"); + if (toggle) toggle.textContent = "退出框选模式"; + document.getElementById("ocrProgress").textContent = "请拖动框选字幕区域"; +} + +// 退出框选模式:恢复原生控件,画布不再拦截指针。 +function exitDrawMode() { + const video = document.getElementById("ocrVideo"); + const canvas = document.getElementById("ocrCanvas"); + const toggle = document.getElementById("ocrDrawMode"); + drawMode = false; + if (video) video.setAttribute("controls", ""); + if (canvas) canvas.classList.remove("drawable"); + if (toggle) toggle.textContent = "进入框选模式"; +} + +// 初始化 OCR 面板:视频预览 + 画布拖动框选。 +function setupOcrPanel() { + const fileInput = document.getElementById("ocrVideoFile"); + const video = document.getElementById("ocrVideo"); + const canvas = document.getElementById("ocrCanvas"); + const resetButton = document.getElementById("ocrReset"); + if (!fileInput || !video || !canvas) return; + + // 选择视频后显示预览并设置画布尺寸与坐标换算。 + // 注意顺序:必须先 reset(会清空旧 src),再设置新 src,否则被清掉。 + fileInput.addEventListener("change", () => { + const file = fileInput.files[0]; + if (!file) return; + resetOcrPanel(); + // 重置会清空 input,这里把文件引用保存下来供提交使用。 + selectedVideoFile = file; + video.src = URL.createObjectURL(file); + video.load(); + video.onloadedmetadata = () => { + const box = document.getElementById("videoBox"); + canvas.width = box.clientWidth; + canvas.height = box.clientHeight; + document.getElementById("ocrProgress").textContent = "请在预览中拖动框选字幕区域"; + }; + }); + + // 框选模式开关:进入/退出。 + const drawToggle = document.getElementById("ocrDrawMode"); + if (drawToggle) { + drawToggle.addEventListener("click", () => { + if (drawMode) { + exitDrawMode(); + } else { + enterDrawMode(); + } + }); + } + + // 拖动绘制框选矩形:mousedown 起点 → mousemove 更新 → mouseup 生成 crop。 + let startX = 0; + let startY = 0; + let drawing = false; + canvas.addEventListener("mousedown", (event) => { + if (!drawMode) return; + const rect = canvas.getBoundingClientRect(); + startX = event.clientX - rect.left; + startY = event.clientY - rect.top; + drawing = true; + }); + canvas.addEventListener("mousemove", (event) => { + if (!drawing || !drawMode) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = "#ff5252"; + ctx.lineWidth = 2; + ctx.strokeRect(Math.min(startX, x), Math.min(startY, y), Math.abs(x - startX), Math.abs(y - startY)); + }); + canvas.addEventListener("mouseup", (event) => { + if (!drawing || !drawMode) return; + drawing = false; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const w = Math.abs(x - startX); + const h = Math.abs(y - startY); + if (w < 5 || h < 5) return; + applyCropRect({ + x: Math.min(startX, x), + y: Math.min(startY, y), + w, + h, + }); + }); + + // 清除框选:清空画布与 crop,禁用提交。 + resetButton.addEventListener("click", () => { + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + selectedCrop = null; + document.getElementById("cropValue").value = ""; + document.getElementById("ocrSubmit").disabled = true; + document.getElementById("ocrProgress").textContent = "请在预览中拖动框选字幕区域"; + }); +} + +// 提交字幕 OCR 任务:携带 crop 覆盖参数,框选完成才可用。 +async function submitOcr() { + const fileInput = document.getElementById("ocrVideoFile"); + const progress = document.getElementById("ocrProgress"); + if (!selectedCrop) { + progress.textContent = "请先框选字幕区域"; + return; + } + const file = selectedVideoFile; + if (!file) { + progress.textContent = "请先选择视频文件"; + return; + } + const workflowId = document.getElementById("workflowSelect").value; + const form = new FormData(); + form.append("file", file); + // 把框选的 crop 传给需要它的节点(如 frame-extract)。 + form.append("params", JSON.stringify({ [cropNodeId]: { crop: selectedCrop } })); + progress.textContent = "上传中..."; + try { + const response = await fetch(`/api/apps/${encodeURIComponent(workflowId)}/runs`, { + method: "POST", + body: form, + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText); + } + progress.textContent = `任务 ${data.id} 已创建,正在跳转到任务管理...`; + window.location.href = `/tasks.html?run=${encodeURIComponent(data.id)}`; + } catch (error) { + progress.textContent = `创建失败:${error.message}`; + } +} + +// 渲染单个任务的进度条 HTML;失败任务用红色填充。 +function progressBar(percent, failed) { + return `
`; +} + +// 为已完成任务生成最终产物下载链接(cn_srt / ass)。 +function artifactLinks(runId, status) { + if (status !== "COMPLETED") return "-"; + return ` + `; +} + +// 加载最近任务并渲染任务表格:状态、当前节点、进度条、产物与重试。 +async function loadRuns() { + const tbody = document.getElementById("runList"); + if (!tbody) return; + const runs = await api("/api/runs"); + tbody.innerHTML = runs.length + ? runs + .map((run) => { + const percent = Math.round((run.progress || 0) * 100); + const failed = run.status === "FAILED"; + // RUNNING/QUEUED 附加耗时或排队提示,其余状态只显示徽章。 + const statusHtml = + run.status === "RUNNING" + ? `${badge(run.status)} 已运行 ${formatElapsed(run.updated_at)}` + : run.status === "QUEUED" + ? `${badge(run.status)} 排队中` + : run.status === "PAUSED" + ? `${badge(run.status)} 已暂停 ${formatElapsed(run.updated_at)}` + : badge(run.status); + // 失败任务提供重试,所有任务均可删除。 + // 排队/运行中可暂停,暂停后可继续,失败可重试,所有任务可删除。 + const canPause = run.status === "RUNNING" || run.status === "QUEUED"; + const canResume = run.status === "PAUSED"; + const actions = ` + ${canPause ? `` : ""} + ${canResume ? `` : ""} + ${failed ? `` : ""} + + `; + return ` + + ${escapeHtml(run.id)} + ${escapeHtml(run.workflow_id)} + ${statusHtml} + ${escapeHtml(run.current_node_id || "-")} + ${progressBar(percent, failed)} ${percent}% + ${escapeHtml(formatTime(run.created_at))} + ${artifactLinks(run.id, run.status)} + ${actions} + `; + }) + .join("") + : '暂无任务,请到首页发起。'; +} + +// 请求后端重试失败任务,成功后刷新列表。 +async function retryRun(runId) { + try { + const result = await api(`/api/runs/${runId}/retry`, { method: "POST" }); + alert(`任务 ${result.id} 已重新排队`); + await loadRuns(); + } catch (error) { + alert(`重试失败:${error.message}`); + } +} + +// 暂停任务:排队或运行中的任务置为 PAUSED,运行中的任务在节点边界停下。 +async function pauseRun(runId) { + try { + const result = await api(`/api/runs/${runId}/pause`, { method: "POST" }); + alert(`任务 ${result.id} 已暂停`); + await loadRuns(); + } catch (error) { + alert(`暂停失败:${error.message}`); + } +} + +// 继续任务:PAUSED 恢复排队,由调度器从断点继续执行。 +async function resumeRun(runId) { + try { + const result = await api(`/api/runs/${runId}/resume`, { method: "POST" }); + alert(`任务 ${result.id} 已恢复执行`); + await loadRuns(); + } catch (error) { + alert(`继续失败:${error.message}`); + } +} +// 删除任务:二次确认后调用后端删除接口并刷新列表。 +async function deleteRun(runId) { + if (!window.confirm(`确认删除任务 ${runId}?相关产物文件将一并删除。`)) { + return; + } + try { + await api(`/api/runs/${runId}`, { method: "DELETE" }); + await loadRuns(); + } catch (error) { + alert(`删除失败:${error.message}`); + } +} + +// 创建或更新工作流:解析 DAG JSON 后提交,随后刷新列表。 +async function createWorkflow() { + const workflowId = document.getElementById("workflowId").value.trim(); + const name = document.getElementById("workflowName").value.trim(); + const description = document.getElementById("workflowDescription").value.trim(); + let definition; + try { + definition = JSON.parse(document.getElementById("workflowDefinition").value); + } catch (error) { + alert(`DAG JSON 无效:${error.message}`); + return; + } + try { + await api("/api/admin/workflows", { + method: "POST", + body: JSON.stringify({ + id: workflowId || undefined, + name, + description, + definition, + }), + }); + alert("工作流已保存"); + await loadWorkflows(); + } catch (error) { + alert(`保存失败:${error.message}`); + } +} + +// 发布指定工作流,使其出现在用户应用中心。 +async function publishWorkflow(workflowId) { + try { + await api(`/api/admin/workflows/${workflowId}/publish`, { method: "POST" }); + await loadWorkflows(); + } catch (error) { + alert(`发布失败:${error.message}`); + } +} + +// 删除指定工作流。 +async function deleteWorkflow(workflowId) { + try { + await api(`/api/admin/workflows/${workflowId}`, { method: "DELETE" }); + await loadWorkflows(); + } catch (error) { + alert(`删除失败:${error.message}`); + } +} + +// 首页发起任务:选择工作流并上传视频,创建任务后跳转到任务管理页查看进度。 +async function uploadVideo() { + const workflowId = document.getElementById("workflowSelect").value; + const fileInput = document.getElementById("videoFile"); + const progress = document.getElementById("runProgress"); + if (!workflowId) { + progress.textContent = "暂无可用工作流"; + return; + } + if (!fileInput.files.length) { + progress.textContent = "请先选择视频文件"; + return; + } + const form = new FormData(); + form.append("file", fileInput.files[0]); + progress.textContent = "上传中..."; + try { + const response = await fetch(`/api/apps/${encodeURIComponent(workflowId)}/runs`, { + method: "POST", + body: form, + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText); + } + progress.textContent = `任务 ${data.id} 已创建,正在跳转到任务管理...`; + // 首页只负责发起任务,进度展示交给任务管理页。 + window.location.href = `/tasks.html?run=${encodeURIComponent(data.id)}`; + } catch (error) { + progress.textContent = `创建失败:${error.message}`; + } +} + +// 全局点击委托:按按钮上的 data-* 属性分发到对应操作。 +document.addEventListener("click", (event) => { + const deleteWorkflowButton = event.target.closest("[data-delete-workflow]"); + if (deleteWorkflowButton) { + deleteWorkflow(deleteWorkflowButton.dataset.deleteWorkflow); + return; + } + const publishWorkflowButton = event.target.closest("[data-publish-workflow]"); + if (publishWorkflowButton) { + publishWorkflow(publishWorkflowButton.dataset.publishWorkflow); + return; + } + const retryButton = event.target.closest("[data-retry-run]"); + if (retryButton) { + retryRun(retryButton.dataset.retryRun); + return; + } + const pauseButton = event.target.closest("[data-pause-run]"); + if (pauseButton) { + pauseRun(pauseButton.dataset.pauseRun); + return; + } + const resumeButton = event.target.closest("[data-resume-run]"); + if (resumeButton) { + resumeRun(resumeButton.dataset.resumeRun); + return; + } + const deleteRunButton = event.target.closest("[data-delete-run]"); + if (deleteRunButton) { + deleteRun(deleteRunButton.dataset.deleteRun); + } +}); + +// 页面初始化:预填 DAG、绑定按钮事件并加载对应页面数据。 +document.addEventListener("DOMContentLoaded", async () => { + const workflowDefinition = document.getElementById("workflowDefinition"); + if (workflowDefinition) { + workflowDefinition.value = JSON.stringify(DEMO_WORKFLOW, null, 2); + } + const createWorkflowButton = document.getElementById("createWorkflow"); + if (createWorkflowButton) { + createWorkflowButton.addEventListener("click", createWorkflow); + } + const publishWorkflowButton = document.getElementById("publishWorkflow"); + if (publishWorkflowButton) { + publishWorkflowButton.addEventListener("click", () => { + publishWorkflow(document.getElementById("workflowId").value.trim()); + }); + } + const uploadButton = document.getElementById("uploadVideo"); + if (uploadButton) { + uploadButton.addEventListener("click", uploadVideo); + // 首页加载已发布工作流供用户选择。 + await loadWorkflowOptions(); + // 字幕 OCR 面板:视频预览 + 拖动框选(仅需 crop 的工作流展示)。 + setupOcrPanel(); + } + const ocrSubmitButton = document.getElementById("ocrSubmit"); + if (ocrSubmitButton) { + ocrSubmitButton.addEventListener("click", submitOcr); + } + + await loadHealth(); + if (document.getElementById("workflowList")) { + await loadWorkflows(); + } + // 任务管理页:每 3 秒刷新一次全部任务的进度,支持高亮跳转参数 run=ID。 + if (document.getElementById("runList")) { + await loadRuns(); + const params = new URLSearchParams(window.location.search); + const target = params.get("run"); + if (target) { + const row = [...document.querySelectorAll("#runList tr")].find((item) => + item.textContent.includes(target), + ); + if (row) { + row.scrollIntoView({ block: "center" }); + row.style.background = "#fff7db"; + } + } + setInterval(loadRuns, 3000); + } +}); diff --git a/web/assets/crop.js b/web/assets/crop.js new file mode 100644 index 0000000..17db3f5 --- /dev/null +++ b/web/assets/crop.js @@ -0,0 +1,38 @@ +// WOV OCR 前端 crop 归一化工具(纯函数,供 node 单测与浏览器共用)。 +// 负责把用户在视频预览上框选的矩形(显示坐标)与 crop 比例 [x,y,w,h](0~1) +// 互转,映射基于视频固有分辨率并处理 object-fit: contain 的留边(letterbox)。 + +// 计算